diff --git a/shine-UI/assets/demo-avatars/u1.jpg b/shine-UI/assets/demo-avatars/u1.jpg
new file mode 100644
index 0000000..f5dfb5a
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u1.jpg differ
diff --git a/shine-UI/assets/demo-avatars/u2.jpg b/shine-UI/assets/demo-avatars/u2.jpg
new file mode 100644
index 0000000..3f8d8f1
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u2.jpg differ
diff --git a/shine-UI/assets/demo-avatars/u3.jpg b/shine-UI/assets/demo-avatars/u3.jpg
new file mode 100644
index 0000000..b706e9e
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u3.jpg differ
diff --git a/shine-UI/assets/demo-avatars/u4.jpg b/shine-UI/assets/demo-avatars/u4.jpg
new file mode 100644
index 0000000..9688fb2
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u4.jpg differ
diff --git a/shine-UI/assets/demo-avatars/u6.jpg b/shine-UI/assets/demo-avatars/u6.jpg
new file mode 100644
index 0000000..970a9fe
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u6.jpg differ
diff --git a/shine-UI/assets/demo-avatars/u7.jpg b/shine-UI/assets/demo-avatars/u7.jpg
new file mode 100644
index 0000000..b530fdb
Binary files /dev/null and b/shine-UI/assets/demo-avatars/u7.jpg differ
diff --git a/shine-UI/docs/design/messages-list-v2.md b/shine-UI/docs/design/messages-list-v2.md
new file mode 100644
index 0000000..827da4f
--- /dev/null
+++ b/shine-UI/docs/design/messages-list-v2.md
@@ -0,0 +1,74 @@
+# Личные сообщения (messages-list) — дизайн v2
+
+Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет
+обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header.
+
+> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository.
+
+## Источник данных
+- **Demo/lab:** мок `js/mock-data.js` → `directMessages` (семантические поля, цвет не хранится).
+- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`).
+- **Маршруты:**
+ - `/messages-list` — защищённый (требует сессии).
+ - `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов).
+
+## Семантика → визуал
+Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится.
+
+Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`,
+`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.)
+
+### Цвета (значение)
+| Цвет | Токен | Значение |
+|------|-------|----------|
+| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) |
+| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь |
+| celestial | `--rel-shining` `#68D8FF` | сияющий |
+| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» |
+
+Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`.
+**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус).
+
+### Приоритет правого статуса
+`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего.
+На карточке максимум ОДИН главный статус.
+
+### Unread
+Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `1–99`, далее `99+`.
+Идёт после статуса, перед chevron.
+
+## Матрица состояний (demo-мок покрывает все)
+| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж |
+|---|---|---|---|---|---|---|---|---|
+| M01 | contact | – | ✓ | – | 0 | violet | 🛡 Подтверждён | – |
+| M02 | contact | – | – | ✓ | 2 | violet | 🔗 Связь | 2 |
+| M03 | contact | ✓ | – | ✓ | 5 | celestial | 🔗 Связь | 5 |
+| M04 | contact | – | – | – | 0 | violet | — | – |
+| M05 | family | – | ✓ | – | 0 | gold | 🛡 Подтверждён | – |
+| M06 | family | – | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 |
+
+## Размеры
+- Карточка: `min-height 92px`, `radius 26px`.
+- Зазор списка: `8px` (flex-column).
+- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри).
+- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill.
+- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`.
+
+## Сияющая сфера — связь с «Связями» (обязательно)
+DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**:
+- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow)
+ + `fg-shine-halo` (дыхание ореола: scale/opacity);
+- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`;
+- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа
+ (узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен);
+- `filter: blur(3.4px)` ≡ `feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow`
+ графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»;
+- `prefers-reduced-motion` → анимации выключаются.
+
+Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров,
+если они уже существуют в визуальном языке «Связей».
+
+## Фон
+Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**.
+Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»).
+Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним.
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 1d0eba7..af12a85 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -63,6 +63,7 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as messagesList from './pages/messages-list.js';
+import * as dmLabChat from './pages/messages/dm-lab-chat.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
import * as userProfileView from './pages/user-profile-view.js';
@@ -105,6 +106,7 @@ const routes = {
'pwa-diagnostics-view': pwaDiagnosticsView,
'solana-users-init-view': solanaUsersInitView,
'messages-list': messagesList,
+ 'dm-lab-chat': dmLabChat,
'contact-search-view': contactSearchView,
'chat-view': chatView,
user: userProfileView,
@@ -151,6 +153,7 @@ const GUEST_ALLOWED_PAGES = new Set([
'channel-thread-view',
'user',
'contact-search-view',
+ 'dm-lab-chat', // demo-чат лаборатории ЛС (мок, без сессии)
]);
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
@@ -687,7 +690,10 @@ function renderApp() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
- if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
+ // Гостю доступен ТОЛЬКО demo-маршрут ЛС (/messages-list/lab) — для оффлайн-проверки редизайна без сессии.
+ // Реальный /messages-list остаётся защищённым (mode пустой → редирект на start-view).
+ const isDmDemo = pageId === 'messages-list' && route.params?.mode === 'lab';
+ if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId) && !isDmDemo) {
navigate('start-view');
return;
}
diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js
index 36380a1..b8f0df3 100644
--- a/shine-UI/js/pages/messages-list.js
+++ b/shine-UI/js/pages/messages-list.js
@@ -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 = '';
+const SVG_LINK = '';
+const SVG_CHEVRON = '';
-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 = `
+
+
${(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 = '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 ? `${SVG_CHECK}` : '';
+ const unreadHtml = v.unread ? `${v.unread.label}` : '';
row.innerHTML = `
-
-
${item.name}
- ${item.notInContacts ? '
не в контактах' : ''}
+
+ ${name}
+ ${checkHtml}
-
${item.lastMessage}
-
-
+
${unreadHtml}${SVG_CHEVRON}
`;
- 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 = `
${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;
}
- 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;
}
diff --git a/shine-UI/js/pages/messages/dm-lab-chat.js b/shine-UI/js/pages/messages/dm-lab-chat.js
new file mode 100644
index 0000000..fd7fbe8
--- /dev/null
+++ b/shine-UI/js/pages/messages/dm-lab-chat.js
@@ -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 = `
+
+
${name}
+
demo
+ `;
+ 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 = `
+
+
+ `;
+ 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;
+}
diff --git a/shine-UI/js/pages/messages/dm-lab-store.js b/shine-UI/js/pages/messages/dm-lab-store.js
new file mode 100644
index 0000000..3c2d81d
--- /dev/null
+++ b/shine-UI/js/pages/messages/dm-lab-store.js
@@ -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());
+}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 9bc56d2..80f9c99 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -150,6 +150,15 @@ export function getRoute() {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
}
+ // messages-list/
: ловим второй сегмент как mode (нужно для demo-маршрута /messages-list/lab).
+ if (pageId === 'messages-list') {
+ // demo-чат под лабораторным префиксом: /messages-list/lab/chat/:id → отдельная demo-страница.
+ if (segments[1] === 'lab' && segments[2] === 'chat' && segments[3]) {
+ return { pageId: 'dm-lab-chat', params: { chatId: decodePart(segments[3]) } };
+ }
+ return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
+ }
+
return { pageId, params: {} };
}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 2bbe08a..3398dc5 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -3553,13 +3553,24 @@ textarea.input {
}
.dm-dialog-card {
- background: rgba(20, 25, 35, 0.4);
- backdrop-filter: blur(25px);
- -webkit-backdrop-filter: blur(25px);
- border: 1px solid rgba(212, 175, 55, 0.4);
- border-radius: 20px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37);
+ position: relative;
+ display: grid;
+ grid-template-columns: 60px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ min-height: 92px; /* карточки не ужимаем; тап-зона ≥44px */
+ padding: 14px 16px 14px 14px;
+ border-radius: 26px;
+ background: rgba(7, 10, 18, 0.88);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42);
+ cursor: pointer;
}
+.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; }
+.dm-card--family { border-color: rgba(240, 184, 46, 0.42); } /* линия связи: gold (семья) */
+.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */
.dm-screen .list-item .avatar {
width: 48px;
@@ -3582,66 +3593,169 @@ textarea.input {
color: rgba(255, 255, 255, 0.5);
}
-.dm-status-line {
- color: rgba(255, 255, 255, 0.5);
+/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */
+/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */
+.dm-screen {
+ --dm-tone-default: var(--rel-contact);
+ --dm-tone-family: var(--rel-family);
+ --dm-tone-shining: var(--rel-shining);
}
-.dm-screen .unread {
- min-width: 26px;
- height: 26px;
- padding: 0 8px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 1px solid rgba(212, 175, 55, 0.5);
- background: rgba(212, 175, 55, 0.22);
- color: rgba(255, 200, 50, 0.95);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
+/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */
+.dm-head {
+ position: sticky; top: 0; z-index: 12;
+ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px;
+ padding: 14px 14px 0;
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
+ background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
+}
+.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.dm-head-hex {
+ width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
+ font-weight: 700; font-size: 15px; color: #1a1205;
+ background: linear-gradient(150deg, #F0B82E, #D49F22);
+ clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
+ box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
+}
+.dm-head-id { min-width: 0; display: grid; }
+.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; }
+/* Центр шапки — светящийся бренд «Shine» */
+.dm-head-shine {
+ font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0;
+ text-shadow: 0 0 6px rgba(240, 184, 46, 0.55), 0 0 16px rgba(240, 184, 46, 0.38), 0 0 30px rgba(240, 184, 46, 0.20);
+ animation: dm-shine-pulse 3.4s ease-in-out infinite;
+}
+@keyframes dm-shine-pulse {
+ 0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); }
+ 50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); }
+}
+@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } }
+.dm-head-plus {
+ justify-self: end; width: 48px; height: 48px; border-radius: 15px;
+ display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300;
+ color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6);
+ background: rgba(12, 12, 16, 0.66);
+ box-shadow: 0 0 20px rgba(240, 184, 46, 0.32), 0 0 6px rgba(240, 184, 46, 0.28), inset 0 0 12px rgba(240, 184, 46, 0.12);
+ cursor: pointer;
+}
+.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; }
+.dm-divider::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); }
+.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
+
+/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
+.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 14px; padding-bottom: calc(86px + 16px); }
+
+/* текст карточки */
+.dm-row-main { min-width: 0; }
+.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
+.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
+.dm-name-check svg { width: 16px; height: 16px; }
+
+/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
+.dm-av { width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; position: relative; }
+.dm-av .avatar { width: 56px; height: 56px; min-width: 56px; min-height: 56px; border: none; box-shadow: none; }
+/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
+.dm-av--default { box-shadow: none; }
+.dm-av--family { box-shadow: none; }
+/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim,
+ тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow — пульс box-shadow,
+ fg-shine-halo — дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем
+ второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на
+ стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная — не размывает текст/соседей. */
+.dm-av--shining {
+ border: 1px solid rgba(150, 240, 255, 0.62);
+ animation: fg-shine-glow 3.6s ease-in-out infinite;
+}
+.dm-av--shining::before {
+ content: ''; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none;
+ background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
+ filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */
+ animation: fg-shine-halo 3.6s ease-in-out infinite;
+}
+@media (prefers-reduced-motion: reduce) {
+ .dm-av--shining { animation: none; }
+ .dm-av--shining::before { animation: none; }
}
-.dm-row-meta-col {
- display: grid;
- justify-items: end;
- align-content: end;
- gap: 6px;
- min-width: 64px;
- align-self: stretch;
+/* правая зона: один статус сверху, ниже [unread + chevron] */
+/* правая зона: один горизонтальный ряд [статус][unread][chevron] на общей оси */
+.dm-dialog-card .dm-row-meta { display: inline-flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; }
+.dm-dialog-card .dm-row-meta .dm-chevron { margin-left: 4px; } /* отступ бейдж→chevron ≈ 12px */
+/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
+.dm-unread-badge {
+ min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 12px; font-weight: 700; color: var(--text);
+ background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55);
}
+.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
+.dm-chevron svg { width: 16px; height: 16px; }
-.dm-row-main {
- min-width: 0;
- display: grid;
- grid-template-rows: auto auto;
- gap: 4px;
+/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
+.dm-via {
+ display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
+ width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer;
+ color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08);
}
+.dm-via-icon { display: inline-flex; }
+.dm-via-icon svg { width: 14px; height: 14px; }
+/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */
+.dm-via-path {
+ display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6;
+ flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px;
+ background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
+}
+.dm-via-path.is-open { display: flex; }
+.dm-via-node {
+ display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px;
+ background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
+ color: var(--text); font-size: 12px; cursor: default;
+}
+button.dm-via-node { cursor: pointer; }
+button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
+.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; }
+.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; }
+.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; }
+.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; }
+.dm-via-node-name { white-space: nowrap; }
+.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); }
-.dm-row-title-wrap {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
+/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
+ даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
+html, body { overflow-x: hidden; }
-.dm-row-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+/* ===== Demo-чат лаборатории ЛС (dm-lab-chat) — только demo, прод chat-view не затрагивает ===== */
+.dm-chat-screen { display: flex; flex-direction: column; min-height: 100%; }
+.dm-chat-head {
+ position: sticky; top: 0; z-index: 12; display: flex; align-items: center; gap: 12px;
+ padding: 14px; border-bottom: 1px solid rgba(240, 184, 46, 0.22);
+ background: linear-gradient(180deg, rgba(10, 12, 18, 0.92), rgba(10, 12, 18, 0.55));
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
-
-.dm-row-last-message {
- margin-top: 0 !important;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- padding-right: 6px;
+.dm-chat-back {
+ flex: 0 0 auto; width: 40px; height: 40px; border-radius: 12px;
+ display: grid; place-items: center; font-size: 26px; line-height: 1;
+ color: var(--rel-family); border: 1px solid rgba(240, 184, 46, 0.4);
+ background: rgba(10, 12, 18, 0.6); cursor: pointer;
}
-
-.dm-row-time {
- font-size: 11px;
- line-height: 1.2;
- white-space: nowrap;
+.dm-chat-peer { flex: 1 1 auto; min-width: 0; font-size: 17px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dm-chat-demo-tag {
+ flex: 0 0 auto; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
+ color: rgba(244, 246, 255, 0.55); padding: 3px 8px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.12);
}
+.dm-chat-screen .dm-messages-log {
+ flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px;
+ padding: 14px; padding-bottom: 16px;
+}
+.dm-chat-screen .bubble.in { align-self: flex-start; }
+.dm-chat-screen .bubble.out { align-self: flex-end; }
+.dm-chat-screen .dm-chat-input { display: grid; }
.dm-chat-wrap {
gap: 12px;
@@ -3777,34 +3891,6 @@ textarea.input {
width: 100%;
}
-/* DM messages-list status + empty block as full glass buttons */
-.dm-screen .dm-status-line {
- display: block;
- width: calc(100% - 40px);
- margin: 2px 20px 10px;
- padding: 12px 16px;
- border-radius: 14px;
- background: rgba(18, 24, 38, 0.42);
- backdrop-filter: blur(25px);
- -webkit-backdrop-filter: blur(25px);
- border: 1px solid rgba(212, 175, 55, 0.32);
- color: rgba(255, 227, 154, 0.92);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
-}
-
-/* Hide "Нет диалогов." line on DM list per UI request */
-.dm-screen .dm-status-line {
- display: none !important;
-}
-
-.dm-screen .dm-status-line.is-available {
- color: rgba(255, 227, 154, 0.92);
-}
-
-.dm-screen .dm-status-line.is-unavailable {
- color: rgba(255, 161, 176, 0.95);
-}
-
.dm-screen .dm-list > .card.meta-muted {
width: calc(100% - 40px);
margin: 0 20px;