SHA256
536 lines
21 KiB
JavaScript
536 lines
21 KiB
JavaScript
import {
|
|
authService,
|
|
getChatMessages,
|
|
isSessionInvalidError,
|
|
normalizeDmChatId,
|
|
setContacts,
|
|
state,
|
|
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 { 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';
|
|
|
|
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();
|
|
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)}…`;
|
|
}
|
|
|
|
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 (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_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 formatRelativeTime(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';
|
|
const head = document.createElement('header');
|
|
head.className = 'dm-head';
|
|
head.innerHTML = `
|
|
<div class="dm-head-brand">
|
|
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
|
</div>
|
|
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
|
<div class="dm-head-menu-wrap">
|
|
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
|
<div class="dm-head-menu" role="menu" hidden>
|
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
|
<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>
|
|
<span>Поиск пользователей</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
head.querySelector('.dm-head-logo-wrap')?.append(
|
|
createShineConnectionsLogo({ className: 'dm-head-logo' }),
|
|
);
|
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
|
menuButton?.append(createOverflowDots());
|
|
|
|
let currentChatFilter = 'all';
|
|
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
|
const filterLabels = {
|
|
all: 'Чаты',
|
|
close_friend: 'Близкие друзья',
|
|
friend: 'Друзья',
|
|
contact: 'Контакты',
|
|
none: 'Новые',
|
|
};
|
|
let reloadForFilter = () => {};
|
|
const chatFilterMenu = createDropdownMenu({
|
|
anchorEl: filterTitle,
|
|
align: 'left',
|
|
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 menuWrap = head.querySelector('.dm-head-menu-wrap');
|
|
const menuTemplate = head.querySelector('.dm-head-menu');
|
|
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
|
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
|
// land on the content layer underneath. Render the open menu as a body portal.
|
|
menuTemplate?.remove();
|
|
|
|
let menuPortal = null;
|
|
|
|
const closeHeadMenu = () => {
|
|
menuPortal?.remove();
|
|
menuPortal = null;
|
|
menuButton?.setAttribute('aria-expanded', 'false');
|
|
menuWrap?.classList.remove('is-open');
|
|
};
|
|
|
|
const positionHeadMenu = () => {
|
|
if (!menuPortal || !menuButton) return;
|
|
const rect = menuButton.getBoundingClientRect();
|
|
const margin = 10;
|
|
const menuWidth = menuPortal.offsetWidth || 206;
|
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
|
menuPortal.style.left = `${Math.round(left)}px`;
|
|
const titleRect = filterTitle?.getBoundingClientRect?.();
|
|
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
|
|
};
|
|
|
|
const openHeadMenu = () => {
|
|
if (!menuButton || menuPortal) return;
|
|
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
|
const portal = document.createElement('div');
|
|
portal.className = 'dm-head-menu dm-head-menu--portal';
|
|
portal.setAttribute('role', 'menu');
|
|
portal.innerHTML = `
|
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
|
<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>
|
|
<span>Поиск пользователей</span>
|
|
</button>
|
|
`;
|
|
|
|
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
closeHeadMenu();
|
|
navigate('contact-search-view');
|
|
});
|
|
portal.addEventListener('click', (event) => event.stopPropagation());
|
|
|
|
document.body.append(portal);
|
|
menuPortal = portal;
|
|
menuButton.setAttribute('aria-expanded', 'true');
|
|
menuWrap?.classList.add('is-open');
|
|
positionHeadMenu();
|
|
};
|
|
|
|
menuButton?.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (menuPortal) closeHeadMenu();
|
|
else openHeadMenu();
|
|
});
|
|
|
|
const onOutsideClick = (event) => {
|
|
if (!menuPortal) return;
|
|
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
|
closeHeadMenu();
|
|
};
|
|
const onPeerDropdownOpen = (event) => {
|
|
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
|
closeHeadMenu();
|
|
};
|
|
const onMenuKeydown = (event) => {
|
|
if (event.key !== 'Escape' || !menuPortal) return;
|
|
closeHeadMenu();
|
|
menuButton?.focus();
|
|
};
|
|
const onMenuViewportChange = () => positionHeadMenu();
|
|
document.addEventListener('click', onOutsideClick);
|
|
document.addEventListener('keydown', onMenuKeydown);
|
|
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
|
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
|
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
|
|
|
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';
|
|
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>
|
|
</div>
|
|
<p class="dm-row-last-message"></p>
|
|
</div>
|
|
<div class="dm-row-meta-col">
|
|
${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 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>
|
|
</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() || 'Диалог пока пуст.';
|
|
});
|
|
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 = 'card meta-muted';
|
|
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) {
|
|
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(list);
|
|
loadList();
|
|
|
|
screen.cleanup = () => {
|
|
closeHeadMenu();
|
|
chatFilterMenu.destroy();
|
|
document.removeEventListener('click', onOutsideClick);
|
|
document.removeEventListener('keydown', onMenuKeydown);
|
|
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
|
window.removeEventListener('resize', onMenuViewportChange);
|
|
window.removeEventListener('scroll', onMenuViewportChange, true);
|
|
};
|
|
|
|
return screen;
|
|
}
|