Files
SHiNE-server/shine-UI/js/pages/messages-list.js
T
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:05:26 +03:00

425 lines
16 KiB
JavaScript

import {
authService,
getChatMessages,
isSessionInvalidError,
normalizeDmChatId,
setContacts,
state,
terminateCurrentSession,
} from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createTopBar } from '../components/topbar.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { formatListTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([
['close_friend', 0],
['friend', 1],
['contact', 2],
['none', 3],
]);
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;
}
function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
firstName: String(firstName || '').trim(),
lastName: String(lastName || '').trim(),
avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null,
size: 'lg',
title,
className,
});
if (!cleanLogin || avatar?.ar) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'lg',
title,
className,
});
upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none';
}
function relationOrder(flag) {
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
}
function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'friend':
return 'друг';
case 'contact':
return 'контакт';
default:
return 'не в контактах';
}
}
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}…`;
}
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
async function resolveDialogPreview(dialog) {
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
if (localText) return localText;
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
if (!blobB64) return 'Диалог пока пуст.';
const cacheKey = [
blobB64,
String(state.session.login || '').trim().toLowerCase(),
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
// чтобы превью появилось, когда пароль снова будет введён.
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
const pending = (async () => {
try {
const parsed = authService.parseSignedMessageBlob(blobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
blobB64,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
const display = clipPreviewText(String(parsedText.displayText || '').trim());
const result = display || 'Сообщение';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = DM_PREVIEW_ENCRYPTED;
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
}
})();
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
return pending;
}
function resolveLocalMessageTimeMs(message) {
const keys = [message?.baseKey, message?.messageKey];
for (const key of keys) {
const parts = String(key || '').split('|');
const timeMs = Number(parts[2] || 0);
if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs;
}
const tempParts = String(message?.tempId || '').split('-');
const tempTimeMs = Number(tempParts[1] || 0);
if (tempParts[0] === 'tmp' && Number.isFinite(tempTimeMs) && tempTimeMs > 0) return tempTimeMs;
const createdAtMs = Number(message?.createdAtMs || message?.ts || 0);
return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : 0;
}
function latestLocalDialogMessage(peerLogin) {
const messages = getChatMessages(peerLogin);
const latest = [...messages].sort((a, b) => resolveLocalMessageTimeMs(b) - resolveLocalMessageTimeMs(a))[0];
if (!latest) return null;
const parsed = parseDmTechBlocks(String(latest?.text || ''));
const text = clipPreviewText(String(parsed.displayText || parsed.visibleText || '').trim()) || 'Сообщение';
return { text, timeMs: resolveLocalMessageTimeMs(latest) };
}
function formatChatRowTime(ts) {
return formatListTime(ts);
}
function compareChatRows(a, b) {
const timeA = Number(a?.lastMessageTimeMs || 0);
const timeB = Number(b?.lastMessageTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.peerLogin || '').toLowerCase();
const nameB = String(b?.peerLogin || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru');
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
let currentChatFilter = 'all';
const CHAT_FILTERS = [
['all', 'Все'],
['close_friend', 'Близкие'],
['friend', 'Друзья'],
['contact', 'Контакты'],
['none', 'Новые'],
];
let reloadForFilter = () => {};
const filterRow = document.createElement('div');
filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
filterRow.setAttribute('role', 'toolbar');
filterRow.setAttribute('aria-label', 'Фильтр чатов');
CHAT_FILTERS.forEach(([value, label]) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'chip';
chip.dataset.filter = value;
chip.textContent = label;
chip.setAttribute('aria-pressed', String(value === currentChatFilter));
chip.addEventListener('click', () => {
if (currentChatFilter === value) return;
currentChatFilter = value;
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
reloadForFilter();
});
filterRow.append(chip);
});
const head = createTopBar({
title: 'Чаты',
className: 'topbar--root',
actions: [
{
icon: 'search',
title: 'Найти человека',
ariaLabel: 'Найти человека',
className: 'messages-search-btn',
onClick: () => navigate('contact-search-view'),
},
],
});
const list = document.createElement('div');
list.className = 'stack dm-list';
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar,
firstName: item.firstName,
lastName: item.lastName,
});
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
avatarWrap.append(avatarEl);
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong>
${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
</div>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
<div class="dm-row-meta-line">
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
</div>
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
</div>
`;
const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time');
if (titleEl) {
const fullName = [String(item.firstName || '').trim(), String(item.lastName || '').trim()].filter(Boolean).join(' ');
titleEl.textContent = fullName || String(item.peerLogin || '');
}
if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap);
void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row;
}
async function loadList() {
if (state.session.isLocalDemo) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Локальный тестовый режим: реальные сообщения и контакты не загружаются.';
list.replaceChildren(empty);
return;
}
try {
const payload = await authService.listContacts();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = dialogs
.filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none')
.map((dialog) => String(dialog?.peerLogin || '').trim())
.filter(Boolean);
setContacts(contacts);
list.innerHTML = '';
const byPeer = new Map();
dialogs.forEach((dialog) => {
const peerLogin = String(dialog?.peerLogin || '').trim();
if (!peerLogin) return;
const key = peerLogin.toLowerCase();
const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
const next = {
id: peerLogin,
peerLogin,
relationFlag,
firstName: String(dialog?.firstName || '').trim(),
lastName: String(dialog?.lastName || '').trim(),
avatar: dialog?.avatar && typeof dialog.avatar === 'object' ? dialog.avatar : null,
accountRole: String(dialog?.accountRole || '').trim(),
shineStatus: String(dialog?.shineStatus || '').trim(),
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unreadCount: Number(dialog?.unreadCount || 0),
hasDialog: Boolean(dialog?.hasDialog),
};
const localLatest = latestLocalDialogMessage(peerLogin);
if (localLatest && localLatest.timeMs >= next.lastMessageTimeMs) {
next.lastMessageText = localLatest.text;
next.lastMessageTimeMs = localLatest.timeMs;
}
const current = byPeer.get(key);
if (!current) {
byPeer.set(key, next);
return;
}
const newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current;
newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag)
? relationFlag
: current.relationFlag;
newest.unreadCount = Math.max(Number(current.unreadCount || 0), Number(next.unreadCount || 0));
newest.hasDialog = Boolean(current.hasDialog || next.hasDialog);
byPeer.set(key, newest);
});
const rows = Array.from(byPeer.values())
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
.sort((a, b) => {
const orderA = relationOrder(a.relationFlag);
const orderB = relationOrder(b.relationFlag);
if (orderA !== orderB) return orderA - orderB;
return compareChatRows(a, b);
});
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'empty-note';
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);
}
}
reloadForFilter = () => { void loadList(); };
chrome?.setTopbar(head);
screen.append(filterRow, list);
loadList();
screen.cleanup = () => {};
return screen;
}