SHA256
Обновить UI-ветку
This commit is contained in:
@@ -938,6 +938,9 @@ function closeTopChannelsMenu(listState) {
|
||||
listState.topMenuCleanup();
|
||||
}
|
||||
listState.topMenuCleanup = null;
|
||||
listState.topMenuAnchor?.setAttribute('aria-expanded', 'false');
|
||||
listState.topMenuAnchor?.classList.remove('menu-open-pressed');
|
||||
listState.topMenuAnchor = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||
@@ -989,9 +992,6 @@ function openTopChannelsMenu({
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
divider.style.margin = '6px 0';
|
||||
menu.append(divider);
|
||||
return;
|
||||
}
|
||||
@@ -1008,6 +1008,9 @@ function openTopChannelsMenu({
|
||||
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
anchorEl.classList.add('menu-open-pressed');
|
||||
listState.topMenuAnchor = anchorEl;
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
@@ -1355,6 +1358,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
topMenuAnchor: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
@@ -1381,11 +1385,16 @@ export function render({ navigate, route, chrome }) {
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.setAttribute('aria-haspopup', 'menu');
|
||||
topMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
if (listState.topMenuAnchor === topMenuBtn) {
|
||||
closeTopChannelsMenu(listState);
|
||||
return;
|
||||
}
|
||||
openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl: topMenuBtn,
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
getChatMessages,
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.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],
|
||||
['contact', 1],
|
||||
['none', 2],
|
||||
]);
|
||||
|
||||
async function loadDmAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
@@ -68,10 +80,72 @@ function createDmAvatar(login, { className = '' } = {}) {
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function resolveLastMessagePreview(text = '') {
|
||||
const parsed = parseDmTechBlocks(String(text || ''));
|
||||
const display = String(parsed.displayText || '').trim();
|
||||
return display || '';
|
||||
function normalizeRelationFlag(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (clean === 'close_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 '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 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 formatChatRowTime(ts) {
|
||||
@@ -86,11 +160,11 @@ function formatChatRowTime(ts) {
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
const timeA = Number(a?.lastTimeMs || 0);
|
||||
const timeB = Number(b?.lastTimeMs || 0);
|
||||
const timeA = Number(a?.lastMessageTimeMs || 0);
|
||||
const timeB = Number(b?.lastMessageTimeMs || 0);
|
||||
if (timeA !== timeB) return timeB - timeA;
|
||||
const nameA = String(a?.name || '').toLowerCase();
|
||||
const nameB = String(b?.name || '').toLowerCase();
|
||||
const nameA = String(a?.peerLogin || '').toLowerCase();
|
||||
const nameB = String(b?.peerLogin || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB, 'ru');
|
||||
}
|
||||
|
||||
@@ -101,17 +175,16 @@ export function render({ navigate, chrome }) {
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand" aria-hidden="true"></div>
|
||||
<h1 class="dm-head-title">Контакты</h1>
|
||||
<h1 class="dm-head-title">Чаты</h1>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
|
||||
</button>
|
||||
<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>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,11 +200,15 @@ export function render({ navigate, chrome }) {
|
||||
menuTemplate?.remove();
|
||||
|
||||
let menuPortal = null;
|
||||
let menuBackdrop = null;
|
||||
|
||||
const closeHeadMenu = () => {
|
||||
menuPortal?.remove();
|
||||
menuBackdrop?.remove();
|
||||
menuPortal = null;
|
||||
menuBackdrop = null;
|
||||
menuButton?.setAttribute('aria-expanded', 'false');
|
||||
menuButton?.classList.remove('menu-open-pressed');
|
||||
menuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
@@ -139,10 +216,16 @@ export function render({ navigate, chrome }) {
|
||||
if (!menuPortal || !menuButton) return;
|
||||
const rect = menuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const menuRect = menuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || menuPortal.offsetWidth || 190;
|
||||
const menuHeight = menuRect.height || menuPortal.offsetHeight || 56;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
menuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
@@ -152,11 +235,7 @@ export function render({ navigate, chrome }) {
|
||||
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>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
@@ -168,11 +247,17 @@ export function render({ navigate, chrome }) {
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeHeadMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
menuBackdrop = backdrop;
|
||||
menuPortal = portal;
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
menuButton.classList.add('menu-open-pressed');
|
||||
menuWrap?.classList.add('is-open');
|
||||
positionHeadMenu();
|
||||
requestAnimationFrame(positionHeadMenu);
|
||||
};
|
||||
|
||||
menuButton?.addEventListener('click', (event) => {
|
||||
@@ -201,10 +286,14 @@ export function render({ navigate, chrome }) {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack dm-list';
|
||||
|
||||
function renderRow(item) {
|
||||
function renderRow(item) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const avatarEl = createDmAvatar(item.id);
|
||||
const relationFlag = normalizeRelationFlag(item.relationFlag);
|
||||
const relationBadge = relationFlag === 'none'
|
||||
? 'не в контактах'
|
||||
: relationLabel(relationFlag);
|
||||
const avatarEl = createDmAvatar(item.peerLogin);
|
||||
avatarEl.classList.add('avatar');
|
||||
const avatarWrap = document.createElement('div');
|
||||
avatarWrap.className = 'dm-av dm-av--default';
|
||||
@@ -213,25 +302,30 @@ export function render({ navigate, chrome }) {
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-titleline dm-row-titlewrap">
|
||||
<strong class="dm-row-title"></strong>
|
||||
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
||||
<span class="dm-contact-note">${relationBadge}</span>
|
||||
</div>
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<div class="dm-row-meta-col">
|
||||
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||
${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.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
${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) titleEl.textContent = String(item.name || '');
|
||||
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
|
||||
if (timeEl) timeEl.textContent = String(item.time || '');
|
||||
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
|
||||
if (previewEl) previewEl.textContent = 'Загрузка…';
|
||||
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
||||
row.prepend(avatarWrap);
|
||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -245,62 +339,64 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const relations = await loadCurrentRelations();
|
||||
const contacts = relations.outContacts || [];
|
||||
const payload = await authService.listContacts();
|
||||
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
||||
setContacts(contacts);
|
||||
list.innerHTML = '';
|
||||
|
||||
const contactRows = contacts.map((login) => {
|
||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
||||
const canonicalLogin = normalizeDmChatId(login);
|
||||
const chat = getChatMessages(canonicalLogin);
|
||||
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: canonicalLogin,
|
||||
name: preview?.name || login,
|
||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
unread,
|
||||
notInContacts: false,
|
||||
lastTimeMs,
|
||||
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,
|
||||
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
||||
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
||||
unreadCount: Number(dialog?.unreadCount || 0),
|
||||
hasDialog: Boolean(dialog?.hasDialog),
|
||||
};
|
||||
const current = byPeer.get(key);
|
||||
if (!current) {
|
||||
byPeer.set(key, next);
|
||||
return;
|
||||
}
|
||||
const currentRank = relationOrder(current.relationFlag);
|
||||
const nextRank = relationOrder(relationFlag);
|
||||
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
|
||||
byPeer.set(key, next);
|
||||
}
|
||||
});
|
||||
|
||||
const allChatIds = Object.keys(state.chats || {})
|
||||
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
||||
.filter((id) => (getChatMessages(id) || []).length > 0);
|
||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
|
||||
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,
|
||||
lastTimeMs,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
||||
empty.textContent = 'Пока нет диалогов';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((item) => list.append(renderRow(item)));
|
||||
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 = '';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
@@ -493,18 +494,113 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
let topMenuPortal = null;
|
||||
let topMenuBackdrop = null;
|
||||
let topMenuButton = null;
|
||||
|
||||
const closeTopMenu = () => {
|
||||
topMenuPortal?.remove();
|
||||
topMenuBackdrop?.remove();
|
||||
topMenuPortal = null;
|
||||
topMenuBackdrop = null;
|
||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
topMenuButton?.classList.remove('menu-open-pressed');
|
||||
};
|
||||
|
||||
const positionTopMenu = () => {
|
||||
if (!topMenuPortal || !topMenuButton) return;
|
||||
const rect = topMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuRect = topMenuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || topMenuPortal.offsetWidth || 190;
|
||||
const menuHeight = menuRect.height || topMenuPortal.offsetHeight || 56;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
topMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
topMenuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openTopMenu = () => {
|
||||
if (!topMenuButton || topMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal network-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item network-head-menu-item" role="menuitem" data-action="find-person">
|
||||
<span>Найти человека</span>
|
||||
</button>
|
||||
`;
|
||||
portal.querySelector('[data-action="find-person"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeTopMenu();
|
||||
openSearchModal();
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeTopMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
topMenuBackdrop = backdrop;
|
||||
topMenuPortal = portal;
|
||||
topMenuButton.setAttribute('aria-expanded', 'true');
|
||||
topMenuButton.classList.add('menu-open-pressed');
|
||||
positionTopMenu();
|
||||
requestAnimationFrame(positionTopMenu);
|
||||
};
|
||||
|
||||
const toggleTopMenu = (event) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (topMenuPortal) closeTopMenu();
|
||||
else openTopMenu();
|
||||
};
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
rightActions: [
|
||||
{ label: 'Найти', onClick: openSearchModal },
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
className: 'network-top-more-btn',
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Меню связей',
|
||||
onClick: toggleTopMenu,
|
||||
},
|
||||
],
|
||||
});
|
||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||
header.classList.add('network-topbar');
|
||||
topMenuButton = header.querySelector('.network-top-more-btn');
|
||||
topMenuButton?.setAttribute('aria-haspopup', 'menu');
|
||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const onTopMenuOutsideClick = (event) => {
|
||||
if (!topMenuPortal) return;
|
||||
if (topMenuPortal.contains(event.target) || topMenuButton?.contains(event.target)) return;
|
||||
closeTopMenu();
|
||||
};
|
||||
const onTopMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !topMenuPortal) return;
|
||||
closeTopMenu();
|
||||
topMenuButton?.focus();
|
||||
};
|
||||
const onTopMenuViewportChange = () => positionTopMenu();
|
||||
document.addEventListener('click', onTopMenuOutsideClick);
|
||||
document.addEventListener('keydown', onTopMenuKeydown);
|
||||
window.addEventListener('resize', onTopMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onTopMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
closeTopMenu();
|
||||
document.removeEventListener('click', onTopMenuOutsideClick);
|
||||
document.removeEventListener('keydown', onTopMenuKeydown);
|
||||
window.removeEventListener('resize', onTopMenuViewportChange);
|
||||
window.removeEventListener('scroll', onTopMenuViewportChange, true);
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
|
||||
@@ -114,11 +114,15 @@ export function render({ navigate, chrome }) {
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
let profileMenuBackdrop = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuBackdrop?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuBackdrop = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuButton?.classList.remove('menu-open-pressed');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
@@ -126,10 +130,16 @@ export function render({ navigate, chrome }) {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const menuRect = profileMenuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || profileMenuPortal.offsetWidth || 210;
|
||||
const menuHeight = menuRect.height || profileMenuPortal.offsetHeight || 144;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
@@ -139,15 +149,12 @@ export function render({ navigate, chrome }) {
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
@@ -161,11 +168,17 @@ export function render({ navigate, chrome }) {
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeProfileMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
profileMenuBackdrop = backdrop;
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuButton.classList.add('menu-open-pressed');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
requestAnimationFrame(positionProfileMenu);
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
|
||||
Reference in New Issue
Block a user