SHA256
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>
This commit is contained in:
@@ -8,21 +8,13 @@ import {
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
import { formatListTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
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"></path>
|
||||
</svg>
|
||||
`;
|
||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
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;
|
||||
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
|
||||
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);
|
||||
|
||||
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = 'Сообщение недоступно';
|
||||
const fallback = DM_PREVIEW_ENCRYPTED;
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
return formatRelativeTime(ts);
|
||||
return formatListTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -201,63 +198,45 @@ 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';
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'dm-head-brand';
|
||||
const logoWrap = document.createElement('span');
|
||||
logoWrap.className = 'dm-head-logo-wrap';
|
||||
logoWrap.setAttribute('aria-hidden', 'true');
|
||||
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
|
||||
brand.append(logoWrap);
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = document.createElement('button');
|
||||
filterTitle.type = 'button';
|
||||
filterTitle.className = 'dm-head-filter-title';
|
||||
filterTitle.textContent = 'Чаты';
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
friend: 'Друзья',
|
||||
contact: 'Контакты',
|
||||
none: 'Новые',
|
||||
};
|
||||
const CHAT_FILTERS = [
|
||||
['all', 'Все'],
|
||||
['close_friend', 'Близкие'],
|
||||
['friend', 'Друзья'],
|
||||
['contact', 'Контакты'],
|
||||
['none', 'Новые'],
|
||||
];
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: filterTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
|
||||
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; 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 searchIconHtml = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
`;
|
||||
const head = createTopBar({
|
||||
left: brand,
|
||||
center: filterTitle,
|
||||
title: 'Чаты',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
|
||||
],
|
||||
},
|
||||
icon: 'search',
|
||||
title: 'Найти человека',
|
||||
ariaLabel: 'Найти человека',
|
||||
className: 'messages-search-btn',
|
||||
onClick: () => navigate('contact-search-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -269,9 +248,7 @@ 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 relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
|
||||
const avatarEl = createDmAvatar(item.peerLogin, {
|
||||
avatar: item.avatar,
|
||||
firstName: item.firstName,
|
||||
@@ -280,19 +257,19 @@ function renderRow(item) {
|
||||
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>
|
||||
<span class="dm-contact-note">${relationBadge}</span>
|
||||
${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>'}
|
||||
<span class="dm-chevron">${SVG_CHEVRON}</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>
|
||||
@@ -310,6 +287,7 @@ function renderRow(item) {
|
||||
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;
|
||||
@@ -387,20 +365,13 @@ function renderRow(item) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет диалогов';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
let dividerInserted = false;
|
||||
rows.forEach((item) => {
|
||||
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
list.append(divider);
|
||||
dividerInserted = true;
|
||||
}
|
||||
list.append(renderRow(item));
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -444,12 +415,10 @@ function renderRow(item) {
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
screen.append(filterRow, list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
chatFilterMenu.destroy();
|
||||
};
|
||||
screen.cleanup = () => {};
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user