From 65fad993ad41b225362e7b7a70fb5a87acdb0e84cfabd1051302d4c5742deb70 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 20 Jun 2026 16:43:53 +0400 Subject: [PATCH 01/10] =?UTF-8?q?UI:=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=80=D1=83=D1=8E=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83=20=D0=9B=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=A1=D0=B2=D1=8F=D0=B7=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 +- shine-UI/js/pages/messages-list.js | 300 +++++++++++------------ shine-UI/js/pages/network/force-graph.js | 24 +- 3 files changed, 171 insertions(+), 157 deletions(-) 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 = ` -
-
${(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'; + screen.append( + renderHeader({ + title: 'Личные сообщения', + leftLabel: String(state.session.login || '').trim(), + rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }], + }), + ); const list = document.createElement('div'); - list.className = 'dm-list'; + list.className = 'stack dm-list'; function renderRow(item) { - const v = resolveDmVisualState(item); // { 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 = (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.className = 'list-item dm-dialog-card'; + const avatarEl = createDmAvatar(item.id); + avatarEl.classList.add('avatar'); row.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'; From 86eaf2139da722f0914ba000ae8a9379340fb26734a06b2335493c4ed4e75d99 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 20 Jun 2026 17:19:32 +0400 Subject: [PATCH 02/10] =?UTF-8?q?UI:=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 +- shine-UI/js/pages/chat-view.js | 6 +- shine-UI/js/pages/contact-search-view.js | 63 +++++++++---- shine-UI/js/pages/messages-list.js | 49 ++++++---- shine-UI/styles/components.css | 114 ++++++++++++++++++++--- 5 files changed, 188 insertions(+), 48 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index b56c45c..db5ff47 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.221 -server.version=1.2.209 +client.version=1.2.222 +server.version=1.2.210 diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 706f55d..6f7a3cb 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -71,7 +71,7 @@ function openMessageActionsMenu({ const menuId = `chat-message-actions-menu-${Date.now()}`; root.innerHTML = `
-