ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-19 19:48:27 +03:00
co-authored by Claude Opus 4.8
parent aea6bbcb0e
commit 6904ac8b7c
13 changed files with 583 additions and 229 deletions
+166 -146
View File
@@ -1,15 +1,10 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import {
getChatMessages,
isSessionInvalidError,
setContacts,
state,
terminateCurrentSession,
} from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.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();
@@ -36,24 +31,36 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
function createDmAvatar(login) {
// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы.
// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются).
function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
title,
});
if (!cleanLogin) return avatarEl;
// Инициалы для 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(),
}
? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
: null,
size: 'small',
title,
@@ -64,149 +71,162 @@ function createDmAvatar(login) {
return avatarEl;
}
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));
}
// Иконки: галочка-подтверждён (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 }) {
export function render({ navigate, route }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
screen.className = 'dm-screen dm-list-screen';
screen.append(
renderHeader({
title: 'Личные сообщения',
leftLabel: String(state.session.login || '').trim(),
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
}),
);
// 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 = 'stack dm-list';
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 = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id);
avatarEl.classList.add('avatar');
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-title-wrap">
<strong class="dm-row-title">${item.name}</strong>
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
<div class="dm-row-titleline">
<strong class="dm-row-title">${name}</strong>
${checkHtml}
</div>
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p>
</div>
<div class="dm-row-meta-col">
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
<span class="meta-muted dm-row-time">${item.time}</span>
<p class="dm-row-last-message">${preview}</p>
</div>
<div class="dm-row-meta">${unreadHtml}<span class="dm-chevron">${SVG_CHEVRON}</span></div>
`;
row.prepend(avatarEl);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
// Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой.
// Тап (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;
}
async function loadList() {
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 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: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: false,
};
});
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,
};
});
const rows = [...contactRows, ...extraRows];
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);
}
// Оффлайн-демо: список из мока 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(list);
loadList();
screen.append(head, divider, list);
return screen;
}
+70
View File
@@ -0,0 +1,70 @@
// Demo-чат для оффлайн-флоу /messages-list/lab/chat/:id (только demo).
// Реальный chat-view.js НЕ трогаем (он завязан на бэкенд/WS); здесь — изолированная мок-страница.
// Состояние сообщений берём из dm-lab-store (localStorage). Без сети, без авторизации.
import { directMessages } from '../../mock-data.js';
import { getThread, appendOut, markRead } from './dm-lab-store.js';
// showAppChrome:false — у чата свой низ (поле ввода), нижнее меню прячем.
export const pageMeta = { id: 'dm-lab-chat', title: 'Чат (demo)', showAppChrome: false };
function findDialog(id) {
return (Array.isArray(directMessages) ? directMessages : []).find((m) => m.id === id) || null;
}
function bubble(m) {
const b = document.createElement('div');
b.className = `bubble ${m && m.from === 'out' ? 'out' : 'in'}`;
b.textContent = m ? m.text : '';
return b;
}
export function render({ navigate, route }) {
const chatId = String(route?.params?.chatId || '').trim();
const dialog = findDialog(chatId);
const name = (dialog && dialog.name) || chatId || 'Диалог';
// Открытие диалога сбрасывает у него непрочитанные (demo).
markRead(chatId);
const screen = document.createElement('section');
screen.className = 'dm-screen dm-chat-screen';
// Шапка чата: назад + имя собеседника + demo-метка.
const head = document.createElement('header');
head.className = 'dm-chat-head';
head.innerHTML = `
<button type="button" class="dm-chat-back" aria-label="Назад"></button>
<span class="dm-chat-peer">${name}</span>
<span class="dm-chat-demo-tag">demo</span>
`;
head.querySelector('.dm-chat-back').addEventListener('click', () => navigate('messages-list/lab'));
const log = document.createElement('div');
log.className = 'dm-messages-log';
getThread(chatId).forEach((m) => log.append(bubble(m)));
const inputRow = document.createElement('form');
inputRow.className = 'dm-chat-input';
inputRow.innerHTML = `
<textarea class="dm-input" rows="1" placeholder="Сообщение…" aria-label="Текст сообщения"></textarea>
<button type="submit" class="dm-send-btn dm-send-icon-btn" aria-label="Отправить">➤</button>
`;
const field = inputRow.querySelector('.dm-input');
const scrollToEnd = () => requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; });
const submit = () => {
const msg = appendOut(chatId, field.value);
if (!msg) return;
field.value = '';
log.append(bubble(msg));
scrollToEnd();
};
inputRow.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
});
screen.append(head, log, inputRow);
scrollToEnd();
return screen;
}
@@ -0,0 +1,89 @@
// Demo-состояние ЛС для оффлайн-флоу /messages-list/lab (только demo, на проде НЕ используется).
// Хранится в localStorage, поэтому переживает навигацию список ↔ чат и перезагрузку.
// Источник стартовых тредов — мок directMessages: последнее сообщение = preview карточки.
import { directMessages } from '../../mock-data.js';
const KEY = 'dm-lab-demo-v1';
function nowLabel() {
try {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
} catch {
return '';
}
}
// Стартовый набор тредов: пара входящих, последнее = preview карточки (чтобы список совпал с моком).
function seed() {
const store = {};
(Array.isArray(directMessages) ? directMessages : []).forEach((m) => {
const last = m.preview || m.lastMessage || 'Сообщение';
store[m.id] = {
unread: Math.max(0, Math.trunc(Number(m.unreadCount) || 0)),
messages: [
{ from: 'in', text: 'Привет! Это тестовый диалог demo-режима.', time: m.time || '' },
{ from: 'in', text: last, time: m.time || '' },
],
};
});
return store;
}
function readAll() {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
}
} catch {}
const fresh = seed();
writeAll(fresh);
return fresh;
}
function writeAll(store) {
try { localStorage.setItem(KEY, JSON.stringify(store)); } catch {}
}
export function getThread(id) {
const all = readAll();
const t = all[id];
return t && Array.isArray(t.messages) ? t.messages : [];
}
export function getUnread(id) {
const all = readAll();
return all[id] ? Math.max(0, Math.trunc(Number(all[id].unread) || 0)) : 0;
}
// Превью для списка = текст последнего сообщения треда (или fallback из мока).
export function getPreview(id, fallback = '') {
const msgs = getThread(id);
const last = msgs[msgs.length - 1];
return last && last.text ? last.text : fallback;
}
// Добавить исходящее сообщение; вернуть его (или null, если текст пуст).
export function appendOut(id, text) {
const clean = String(text || '').trim();
if (!id || !clean) return null;
const all = readAll();
if (!all[id]) all[id] = { unread: 0, messages: [] };
const msg = { from: 'out', text: clean, time: nowLabel() };
all[id].messages.push(msg);
writeAll(all);
return msg;
}
// Открытие диалога сбрасывает непрочитанные у него.
export function markRead(id) {
const all = readAll();
if (all[id]) { all[id].unread = 0; writeAll(all); }
}
// На случай отладки: сбросить demo-состояние к стартовому.
export function resetDemo() {
writeAll(seed());
}