diff --git a/VERSION.properties b/VERSION.properties
index 985e3b0..b56c45c 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.220
-server.version=1.2.208
+client.version=1.2.221
+server.version=1.2.209
diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js
index 12edefb..5885fe7 100644
--- a/shine-UI/js/pages/messages-list.js
+++ b/shine-UI/js/pages/messages-list.js
@@ -1,9 +1,15 @@
+import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
-import { state } from '../state.js';
+import {
+ getChatMessages,
+ isSessionInvalidError,
+ setContacts,
+ state,
+ terminateCurrentSession,
+} from '../state.js';
+import { loadCurrentRelations } from '../services/user-connections.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 { makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const dmAvatarSnapshotCache = new Map();
@@ -30,36 +36,24 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
-// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы.
-// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются).
-function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
+function createDmAvatar(login) {
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;
+ const avatarEl = renderUserAvatar({
+ login: cleanLogin || 'unknown',
+ size: 'small',
+ title,
+ });
+ if (!cleanLogin) 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,
@@ -70,151 +64,149 @@ function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
return avatarEl;
}
-// Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон.
-const SVG_CHECK = '';
-const SVG_LINK = '';
-const SVG_CHEVRON = '';
+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));
+}
-export function render({ navigate, route }) {
+export function render({ navigate }) {
const screen = document.createElement('section');
- screen.className = 'dm-screen dm-list-screen';
+ screen.className = 'stack dm-screen dm-list-screen';
- // Слева сверху — имя владельца аккаунта (реальный логин из сессии).
- const login = String(state.session.login || '').trim();
-
- // DM-шапка: grid 1fr auto 1fr (бренд слева, title строго по центру, «+» справа).
- const head = document.createElement('header');
- head.className = 'dm-head';
- head.innerHTML = `
-
-
-
${name}
- ${checkHtml}
+
+ ${item.name}
+ ${item.notInContacts ? 'не в контактах' : ''}
-
${preview}
+
${item.lastMessage}
+
+
+ ${item.unread ? `${item.unread}` : ''}
+ ${item.time}
-
${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: true, name });
- avatarEl.classList.add('avatar');
- avWrap.appendChild(avatarEl);
- row.prepend(avWrap);
-
- const go = () => navigate(`chat-view/${encodeURIComponent(item.id)}`);
- row.addEventListener('click', go);
- row.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
- });
+ row.prepend(avatarEl);
+ row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
return row;
}
- // Источник списка — мок directMessages (плейсхолдер). На проде заменяется реальными
- // relations/chats (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)));
+ 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);
+ }
}
- screen.append(head, divider, list);
+ screen.append(list);
+ loadList();
return screen;
}
diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js
index b23f56e..ffa297f 100644
--- a/shine-UI/js/pages/network/force-graph.js
+++ b/shine-UI/js/pages/network/force-graph.js
@@ -15,6 +15,8 @@
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
+import { state } from '../../state.js';
+import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
const SVGNS = 'http://www.w3.org/2000/svg';
@@ -105,6 +107,26 @@ function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
}
+function resolveAvatarPhotoSrc(src) {
+ const directPhoto = String(src?.photo || '').trim();
+ if (directPhoto) return directPhoto;
+
+ const rawAvatar = src?.avatar;
+ if (!rawAvatar || rawAvatar === 'url_to_image') return null;
+ if (typeof rawAvatar === 'string') return String(rawAvatar).trim() || null;
+
+ const txId = String(rawAvatar?.ar || '').trim();
+ if (!txId) return null;
+ try {
+ return buildArweaveDataUrl({
+ gateway: state?.entrySettings?.arweaveServer,
+ txId,
+ });
+ } catch {
+ return null;
+ }
+}
+
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
function ensureShineFilter() {
@@ -487,7 +509,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
// синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
- const photoSrc = src.photo || (src.avatar && src.avatar !== 'url_to_image' ? src.avatar : null);
+ const photoSrc = resolveAvatarPhotoSrc(src);
const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
const dot = document.createElement('div');
dot.className = 'avatar node-dot fg-orb-host';