Compare commits

..
Author SHA256 Message Date
tram 97f90167f1 Добавить поддержку уведомлений 2026-08-21 13:23:41 +03:00
21 changed files with 300 additions and 3036 deletions
-2
View File
@@ -1,5 +1,3 @@
import org.bouncycastle.util.Properties
plugins { plugins {
id 'java' id 'java'
id 'application' id 'application'
+2 -2
View File
@@ -12,13 +12,13 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260823123601'; window.__SHINE_BUILD_HASH__ = '20260819190000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev'); const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css']; const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
cssFiles.forEach((file) => { cssFiles.forEach((file) => {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
+6 -29
View File
@@ -5,7 +5,6 @@ import {
syncTrackedRouteHistory, syncTrackedRouteHistory,
} from './router.js'; } from './router.js';
import { renderToolbar } from './components/toolbar.js'; import { renderToolbar } from './components/toolbar.js';
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'; import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
@@ -81,17 +80,17 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js'; import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js'; import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js?v=202608221218'; import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js'; import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608221218'; import * as chatView from './pages/chat-view.js?v=202608191738';
import * as userProfileView from './pages/user-profile-view.js'; import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218'; import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
import * as channelThreadView from './pages/channel-thread-view.js'; import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js'; import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
import * as networkView from './pages/network-view.js?v=202608221226'; import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js?v=202608221354'; import * as notificationsView from './pages/notifications-view.js';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается'; const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -191,15 +190,6 @@ let initialConnectionCompleted = false;
let orientationLockInFlight = false; let orientationLockInFlight = false;
let currentChromeCleanup = null; let currentChromeCleanup = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'messages-list',
'chat-view',
'channels-list',
'channel-view',
'channel-thread-view',
'notifications-view',
]);
const GUEST_ALLOWED_PAGES = new Set([ const GUEST_ALLOWED_PAGES = new Set([
'start-view', 'start-view',
'entry-settings-view', 'entry-settings-view',
@@ -1138,14 +1128,6 @@ function renderPageFailureFallback(pageId, error) {
refreshConnectionUi(); refreshConnectionUi();
} }
function attachPageScrollToBottom(pageId, screen) {
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
return attachScrollToBottomButton({
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
});
}
function renderApp() { function renderApp() {
syncTrackedRouteHistory(window.location.pathname || '/'); syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute(); const route = getRoute();
@@ -1182,12 +1164,7 @@ function renderApp() {
} }
screenEl.append(screen); screenEl.append(screen);
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
currentCleanup = () => {
pageCleanup?.();
scrollToBottomControl?.cleanup();
};
screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
+10 -33
View File
@@ -25,28 +25,27 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
return (cleanLogin[0] || '?').toUpperCase(); return (cleanLogin[0] || '?').toUpperCase();
} }
export function renderAvatar({ export function renderUserAvatar({
initials = '?', login,
firstName = '',
lastName = '',
avatar = null, avatar = null,
size = 'large', size = 'large',
className = '', className = '',
title = '', title = '',
alt = 'Аватар',
glow = false,
} = {}) { } = {}) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']); const classes = ['avatar', 'avatar-image'];
const sizeClass = pickSizeClass(size); const sizeClass = pickSizeClass(size);
if (sizeClass) classes.add(sizeClass); if (sizeClass) classes.push(sizeClass);
const extraClass = String(className || '').trim(); const extraClass = String(className || '').trim();
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value)); if (extraClass) classes.push(...extraClass.split(/\s+/g));
if (glow) classes.add('avatar-glow'); wrap.className = classes.join(' ');
wrap.className = Array.from(classes).join(' ');
if (title) wrap.title = String(title); if (title) wrap.title = String(title);
const fallback = document.createElement('span'); const fallback = document.createElement('span');
fallback.className = 'avatar-fallback'; fallback.className = 'avatar-fallback';
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?'; fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
wrap.append(fallback); wrap.append(fallback);
const txId = String(avatar?.ar || '').trim(); const txId = String(avatar?.ar || '').trim();
@@ -57,8 +56,7 @@ export function renderAvatar({
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : ''; const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'avatar-photo'; img.alt = 'Аватар';
img.alt = String(alt || 'Аватар');
img.loading = 'lazy'; img.loading = 'lazy';
img.decoding = 'async'; img.decoding = 'async';
wrap.append(img); wrap.append(img);
@@ -133,24 +131,3 @@ export function renderAvatar({
return wrap; return wrap;
} }
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
avatar = null,
size = 'large',
className = '',
title = '',
glow = false,
} = {}) {
return renderAvatar({
initials: buildAvatarInitials({ login, firstName, lastName }),
avatar,
size,
className,
title,
alt: 'Аватар',
glow,
});
}
+5 -18
View File
@@ -1,20 +1,14 @@
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) { export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
const wrap = document.createElement('header'); const wrap = document.createElement('header');
wrap.className = 'page-header app-topbar-shell'; wrap.className = 'page-header';
const left = document.createElement('div'); const left = document.createElement('div');
left.className = 'header-left'; left.className = 'header-left';
if (leftAction) { if (leftAction) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
const rawLabel = String(leftAction.label || '').trim(); btn.className = 'icon-btn';
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === ''; btn.textContent = leftAction.label;
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
btn.textContent = isBackAction ? '←' : rawLabel;
if (isBackAction) {
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
btn.title = leftAction.title || 'Назад';
}
btn.addEventListener('click', leftAction.onClick); btn.addEventListener('click', leftAction.onClick);
left.append(btn); left.append(btn);
} }
@@ -25,16 +19,9 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
left.append(label); left.append(label);
} }
const center = document.createElement('div');
center.className = 'header-center';
if (centerNode instanceof Node) {
center.append(centerNode);
} else {
const h1 = document.createElement('h1'); const h1 = document.createElement('h1');
h1.className = 'page-title'; h1.className = 'page-title';
h1.textContent = title; h1.textContent = title;
center.append(h1);
}
const right = document.createElement('div'); const right = document.createElement('div');
right.className = 'header-actions'; right.className = 'header-actions';
@@ -53,6 +40,6 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
right.append(btn); right.append(btn);
}); });
wrap.append(left, center, right); wrap.append(left, h1, right);
return wrap; return wrap;
} }
-10
View File
@@ -1,10 +0,0 @@
export function createOverflowDots({ className = '' } = {}) {
const dots = document.createElement('span');
const extra = String(className || '').trim();
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
dots.setAttribute('aria-hidden', 'true');
for (let i = 0; i < 3; i += 1) {
dots.append(document.createElement('i'));
}
return dots;
}
@@ -1,144 +0,0 @@
function resolveElement(value) {
return typeof value === 'function' ? value() : value;
}
function buildArrowIcon() {
const icon = document.createElement('span');
icon.className = 'scroll-to-bottom-btn__icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '↓';
return icon;
}
export function attachScrollToBottomButton({
scrollContainer,
mountTarget = document.querySelector('.app-shell'),
thresholdPx = 160,
title = 'Вниз',
} = {}) {
const target = resolveElement(mountTarget);
if (!(target instanceof Element)) {
return {
button: null,
refresh() {},
cleanup() {},
};
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'scroll-to-bottom-btn';
button.title = title;
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
button.tabIndex = -1;
button.append(buildArrowIcon());
target.append(button);
let disposed = false;
let boundContainer = null;
let resizeObserver = null;
let mutationObserver = null;
let refreshFrame = 0;
const hide = () => {
button.classList.remove('is-visible');
button.setAttribute('aria-hidden', 'true');
button.tabIndex = -1;
};
const scheduleRefresh = () => {
if (disposed || refreshFrame) return;
refreshFrame = window.requestAnimationFrame(() => {
refreshFrame = 0;
refresh();
});
};
const unbindContainer = () => {
boundContainer?.removeEventListener('scroll', scheduleRefresh);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
resizeObserver = null;
mutationObserver = null;
boundContainer = null;
};
const bindContainer = () => {
const nextContainer = resolveElement(scrollContainer);
if (!(nextContainer instanceof Element)) {
if (boundContainer) unbindContainer();
return null;
}
if (nextContainer === boundContainer) return boundContainer;
unbindContainer();
boundContainer = nextContainer;
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
if (typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(scheduleRefresh);
resizeObserver.observe(boundContainer);
}
if (typeof MutationObserver === 'function') {
mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
}
return boundContainer;
};
function refresh() {
if (disposed) return;
const container = bindContainer();
if (!container) {
hide();
return;
}
const scrollHeight = Number(container.scrollHeight || 0);
const clientHeight = Number(container.clientHeight || 0);
const scrollTop = Number(container.scrollTop || 0);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
button.classList.toggle('is-visible', shouldShow);
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
button.tabIndex = shouldShow ? 0 : -1;
}
const scrollToBottom = () => {
const container = bindContainer();
if (!container) return;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
} else {
container.scrollTop = container.scrollHeight;
}
scheduleRefresh();
};
button.addEventListener('click', scrollToBottom);
window.addEventListener('resize', scheduleRefresh);
window.visualViewport?.addEventListener('resize', scheduleRefresh);
window.requestAnimationFrame(refresh);
return {
button,
refresh: scheduleRefresh,
cleanup() {
if (disposed) return;
disposed = true;
if (refreshFrame) {
window.cancelAnimationFrame(refreshFrame);
refreshFrame = 0;
}
unbindContainer();
window.removeEventListener('resize', scheduleRefresh);
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
button.removeEventListener('click', scrollToBottom);
button.remove();
},
};
}
+11 -10
View File
@@ -10,7 +10,7 @@ import {
openArweaveAttachmentManager, openArweaveAttachmentManager,
markArweaveAttachmentPlaced, markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js'; } from '../components/arweave-attachment-manager.js';
import { renderAvatar } from '../components/avatar-image.js'; import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' }; export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
@@ -40,16 +40,17 @@ function normalizeMetaText(value, max, label) {
function renderAvatarPreview(slot, avatar, title) { function renderAvatarPreview(slot, avatar, title) {
if (!slot) return; if (!slot) return;
slot.innerHTML = ''; slot.innerHTML = '';
const label = String(title || 'К').trim() || 'К'; const wrap = document.createElement('div');
const wrap = renderAvatar({ wrap.className = 'channel-profile-avatar';
initials: label.slice(0, 1).toUpperCase() || 'К',
avatar,
size: 'small',
className: 'channel-profile-avatar',
title: label,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', '104px'); wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap); slot.append(wrap);
} }
+1 -3
View File
@@ -6,7 +6,6 @@ import {
readArweaveAttachmentHistory, readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js'; } from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js'; import { formatBytes } from '../services/attachment-format.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js'; import { state } from '../state.js';
@@ -74,14 +73,13 @@ export function render({ navigate }) {
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button> <button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div> <div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button> <button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button> <button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden> <div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button> <button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
<button class="text-btn" type="button" data-action="clear">Очистить историю</button> <button class="text-btn" type="button" data-action="clear">Очистить историю</button>
<button class="text-btn" type="button" data-action="help">Справка</button> <button class="text-btn" type="button" data-action="help">Справка</button>
</div> </div>
`; `;
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
const statusLine = document.createElement('p'); const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error'; statusLine.className = 'meta-muted inline-error';
+22 -21
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js'; import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js'; import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
@@ -1144,26 +1144,31 @@ export function render({ navigate, route, chrome }) {
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean'); appScreen?.classList.add('channels-scroll-clean');
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
const header = renderHeader({ const header = renderHeader({
centerNode: threadHeaderButton, title: '',
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) }, leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [ rightActions: [],
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
}); });
header.classList.add('channel-thread-topbar'); header.classList.add('channel-thread-topbar');
const headerLeft = header.querySelector('.header-left');
let threadHeaderButton = null;
if (headerLeft) {
const channelsListButton = document.createElement('button');
channelsListButton.type = 'button';
channelsListButton.className = 'icon-btn';
channelsListButton.textContent = '↑';
channelsListButton.title = 'К списку каналов';
channelsListButton.setAttribute('aria-label', 'К списку каналов');
channelsListButton.addEventListener('click', () => navigate('channels-list'));
headerLeft.append(channelsListButton);
threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
headerLeft.append(threadHeaderButton);
}
chrome?.setTopbar(header); chrome?.setTopbar(header);
const statusBox = document.createElement('div'); const statusBox = document.createElement('div');
@@ -1175,7 +1180,6 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--thread'); const current = document.querySelector('section.channels-screen--thread');
if (!current) return; if (!current) return;
const next = render({ navigate, route }); const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next); current.replaceWith(next);
} catch (error) { } catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname }); logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
@@ -1348,9 +1352,6 @@ export function render({ navigate, route, chrome }) {
invalid.className = 'card meta-muted'; invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.'; invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid); screen.append(invalid);
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen; return screen;
} }
+20 -20
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { import {
authService, authService,
getMessageReactionState, getMessageReactionState,
@@ -16,7 +16,7 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
@@ -31,6 +31,7 @@ import {
makeProfileRoute, makeProfileRoute,
makeShineMessageRoute, makeShineMessageRoute,
} from '../services/shine-routes.js'; } from '../services/shine-routes.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
@@ -386,17 +387,18 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
} }
function createChannelAvatarElement(channel, size = 72) { function createChannelAvatarElement(channel, size = 72) {
const txId = String(channel?.avaAr || '').trim(); const wrap = document.createElement('div');
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К'; wrap.className = 'channel-profile-avatar';
const wrap = renderAvatar({
initials: title.slice(0, 1).toUpperCase() || 'К',
avatar: txId ? { ar: txId } : null,
size: 'small',
className: 'channel-profile-avatar',
title,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', `${size}px`); wrap.style.setProperty('--channel-avatar-size', `${size}px`);
const txId = String(channel?.avaAr || '').trim();
if (txId) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
wrap.append(img);
} else {
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
return wrap; return wrap;
} }
@@ -2054,20 +2056,19 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = ''; statusBox.style.display = '';
}; };
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
const header = renderHeader({ const header = renderHeader({
centerNode: channelHeaderButton, title: '',
leftAction: { label: '<', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [ rightActions: [
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
], ],
}); });
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn'); const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
if (channelHeaderButton) {
channelHeaderButton.disabled = true;
}
if (channelEntrypointButton) { if (channelEntrypointButton) {
channelEntrypointButton.disabled = true; channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true; channelEntrypointButton.hidden = true;
@@ -2078,7 +2079,6 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--channel'); const current = document.querySelector('section.channels-screen--channel');
if (!current) return; if (!current) return;
const next = render({ navigate, route }); const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next); current.replaceWith(next);
}; };
let activeSelector = null; let activeSelector = null;
+64 -37
View File
@@ -1,4 +1,4 @@
import { channels as mockChannels } from '../mock-data.js'; import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js'; import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js'; import { parseMessageAttachments } from '../services/attachment-format.js';
@@ -12,8 +12,8 @@ import {
writeChannelNotificationsState, writeChannelNotificationsState,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js'; import { navigateBack } from '../router.js';
import { createOverflowDots } from '../components/overflow-dots.js'; import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -90,16 +90,6 @@ function avatarLetterFromName(name = '') {
return first.toUpperCase(); return first.toUpperCase();
} }
function createChannelAvatar(channel = {}) {
return renderAvatar({
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'small',
title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала',
});
}
function allFeedSummaries() { function allFeedSummaries() {
const feed = state.channelsFeed || {}; const feed = state.channelsFeed || {};
return [ return [
@@ -895,6 +885,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'channel-row'; row.className = 'channel-row';
row.innerHTML = ` row.innerHTML = `
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
<div class="channel-row-main"> <div class="channel-row-main">
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong> <strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p> <p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
@@ -903,7 +894,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
<span class="channel-row-time"></span> <span class="channel-row-time"></span>
</div> </div>
`; `;
row.prepend(createChannelAvatar(channel));
row.addEventListener('click', () => { row.addEventListener('click', () => {
const route = channel.route || makeShineChannelRoute({ const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'), ownerLogin: String(channel.ownerName || 'channel'),
@@ -938,9 +928,6 @@ function closeTopChannelsMenu(listState) {
listState.topMenuCleanup(); listState.topMenuCleanup();
} }
listState.topMenuCleanup = null; 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'); const root = document.getElementById('modal-root');
if (root) { if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`); const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
@@ -952,6 +939,7 @@ function openTopChannelsMenu({
listState, listState,
anchorEl, anchorEl,
navigate, navigate,
onSubscribeChannel,
onFindChannel, onFindChannel,
}) { }) {
closeTopChannelsMenu(listState); closeTopChannelsMenu(listState);
@@ -963,7 +951,7 @@ function openTopChannelsMenu({
let left = rect.right - menuWidth; let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12)); left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250; const estimatedHeight = 320;
let top = rect.bottom + 8; let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) { if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8); top = Math.max(12, rect.top - estimatedHeight - 8);
@@ -980,18 +968,23 @@ function openTopChannelsMenu({
menu.style.width = `${Math.round(menuWidth)}px`; menu.style.width = `${Math.round(menuWidth)}px`;
const items = [ const items = [
{ label: 'Поиск', action: () => onFindChannel?.() },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) }, { label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) }, { label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) }, { label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
{ divider: true },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
]; ];
items.forEach((item) => { items.forEach((item) => {
if (item.divider) { if (item.divider) {
const divider = document.createElement('div'); const divider = document.createElement('div');
divider.className = 'channel-menu-divider'; 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); menu.append(divider);
return; return;
} }
@@ -1008,9 +1001,6 @@ function openTopChannelsMenu({
overlay.append(menu); overlay.append(menu);
root.append(overlay); root.append(overlay);
anchorEl.setAttribute('aria-expanded', 'true');
anchorEl.classList.add('menu-open-pressed');
listState.topMenuAnchor = anchorEl;
const onOverlayClick = (event) => { const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState); if (event.target === overlay) closeTopChannelsMenu(listState);
@@ -1216,7 +1206,16 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const countersVisible = listState.revealedCounters.has(channel.id); const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible); row.classList.toggle('is-counters-visible', countersVisible);
const avatar = createChannelAvatar(channel); const avatar = document.createElement('div');
avatar.className = 'avatar';
if (channel.avaAr) {
const img = document.createElement('img');
img.alt = '';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
avatar.append(img);
} else {
avatar.textContent = channel.avatar;
}
const main = renderChannelMain(channel); const main = renderChannelMain(channel);
@@ -1238,7 +1237,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const menuButton = document.createElement('button'); const menuButton = document.createElement('button');
menuButton.type = 'button'; menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger'; menuButton.className = 'channel-menu-trigger';
menuButton.append(createOverflowDots()); menuButton.textContent = '…';
menuButton.addEventListener('click', (event) => { menuButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(menuButton); animatePress(menuButton);
@@ -1358,7 +1357,6 @@ export function render({ navigate, route, chrome }) {
const listState = { const listState = {
openMenuId: null, openMenuId: null,
topMenuCleanup: null, topMenuCleanup: null,
topMenuAnchor: null,
notificationsState, notificationsState,
revealedCounters: new Set(), revealedCounters: new Set(),
channels: [], channels: [],
@@ -1375,36 +1373,63 @@ export function render({ navigate, route, chrome }) {
const topBarLeft = document.createElement('div'); const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left'; topBarLeft.className = 'channels-top-left';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.className = 'icon-btn channels-top-back-btn';
backBtn.textContent = '←';
backBtn.setAttribute('aria-label', 'Назад');
backBtn.addEventListener('click', () => navigateBack());
const topTitle = document.createElement('strong'); const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title'; topTitle.className = 'channels-top-title';
const topBarRight = document.createElement('div'); const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right'; topBarRight.className = 'channels-top-right';
const findChannelBtn = document.createElement('button');
findChannelBtn.type = 'button';
findChannelBtn.className = 'icon-btn channels-top-search-btn';
findChannelBtn.setAttribute('aria-label', 'Найти канал');
findChannelBtn.title = 'Найти канал';
const findChannelIcon = document.createElement('span');
findChannelIcon.className = 'channels-search-icon';
findChannelIcon.setAttribute('aria-hidden', 'true');
findChannelBtn.append(findChannelIcon);
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn';
createInMyBtn.textContent = '+';
createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
const topMenuBtn = document.createElement('button'); const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button'; topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn'; topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия'); topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.setAttribute('aria-haspopup', 'menu');
topMenuBtn.setAttribute('aria-expanded', 'false');
topMenuBtn.title = 'Ещё действия'; topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots()); topMenuBtn.textContent = '⋮';
topMenuBtn.addEventListener('click', (event) => { topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
if (listState.topMenuAnchor === topMenuBtn) { animatePress(topMenuBtn);
closeTopChannelsMenu(listState);
return;
}
openTopChannelsMenu({ openTopChannelsMenu({
listState, listState,
anchorEl: topMenuBtn, anchorEl: topMenuBtn,
navigate, navigate,
onFindChannel: () => openChannelFinderModal({ navigate }), onFindChannel: () => openChannelFinderModal({ navigate }),
onSubscribeChannel: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Добавить канал',
submitLabel: 'Добавить',
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
}),
}); });
}); });
topBarRight.append(topMenuBtn); topBarLeft.append(backBtn, topTitle);
topBarEl.append(topBarLeft, topTitle, topBarRight); topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
topBarEl.append(topBarLeft, topBarRight);
const bottomCta = document.createElement('button'); const bottomCta = document.createElement('button');
bottomCta.type = 'button'; bottomCta.type = 'button';
@@ -1425,8 +1450,10 @@ export function render({ navigate, route, chrome }) {
}); });
topTitle.textContent = channelsViewTitle(listState.viewMode); topTitle.textContent = channelsViewTitle(listState.viewMode);
findChannelBtn.style.display = '';
createInMyBtn.style.display = '';
topMenuBtn.style.display = ''; topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight); if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
updateBottomCta({ button: bottomCta }); updateBottomCta({ button: bottomCta });
}; };
+7 -54
View File
@@ -1,6 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { import {
addAppLogEntry, addAppLogEntry,
@@ -29,55 +27,10 @@ import {
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js'; import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function createChatHeaderParts(login) {
const cleanLogin = String(login || '').trim() || 'unknown';
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
login: cleanLogin,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
const loginEl = document.createElement('span');
loginEl.className = 'chat-header-login';
loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.textContent = cleanLogin;
void loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
if (!avatarSlot.isConnected) return;
const upgradedAvatar = renderUserAvatar({
login: cleanLogin,
firstName: String(snapshot?.firstName || '').trim(),
lastName: String(snapshot?.lastName || '').trim(),
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
})
.catch(() => {});
return { centerNode: loginEl, avatarSlot };
}
function truncatePreviewText(value, maxLen = 72) { function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim(); const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return ''; if (!normalized) return '';
@@ -953,6 +906,7 @@ export function render({ navigate, route, chrome }) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap'; wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div'); const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader'; historyLoader.className = 'dm-history-loader';
historyLoader.hidden = true; historyLoader.hidden = true;
@@ -966,9 +920,9 @@ export function render({ navigate, route, chrome }) {
const log = document.createElement('div'); const log = document.createElement('div');
log.className = 'messages-log dm-messages-log'; log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId); chrome?.setTopbar(
const chatHeader = renderHeader({ renderHeader({
centerNode: chatHeaderParts.centerNode, title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') }, leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [ rightActions: [
{ {
@@ -979,7 +933,7 @@ export function render({ navigate, route, chrome }) {
onClick: () => handleStartCall('audio'), onClick: () => handleStartCall('audio'),
}, },
{ {
iconNode: createOverflowDots(), label: '⋮',
title: 'Действия чата', title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата', ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn', className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -1036,9 +990,8 @@ export function render({ navigate, route, chrome }) {
}, },
}, },
], ],
}); }),
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot); );
chrome?.setTopbar(chatHeader);
if (!isKnownContact) { if (!isKnownContact) {
const card = document.createElement('div'); const card = document.createElement('div');
+91 -175
View File
@@ -1,34 +1,21 @@
import { directMessages } from '../mock-data.js';
import { import {
authService, getChatMessages,
isSessionInvalidError, isSessionInvalidError,
normalizeDmChatId, normalizeDmChatId,
setContacts, setContacts,
state, state,
terminateCurrentSession, terminateCurrentSession,
} from '../state.js'; } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; 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 dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map(); const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([
['close_friend', 0],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) { async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null; if (!cleanLogin) return null;
@@ -50,14 +37,13 @@ async function loadDmAvatarSnapshot(login) {
return pending; return pending;
} }
function createDmAvatar(login, { className = '' } = {}) { function createDmAvatar(login) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({ const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown', login: cleanLogin || 'unknown',
size: 'small', size: 'small',
title, title,
className,
}); });
if (!cleanLogin) return avatarEl; if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
@@ -72,7 +58,6 @@ function createDmAvatar(login, { className = '' } = {}) {
: null, : null,
size: 'small', size: 'small',
title, title,
className,
}); });
upgraded.classList.add('avatar'); upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded); avatarEl.replaceWith(upgraded);
@@ -80,72 +65,10 @@ function createDmAvatar(login, { className = '' } = {}) {
return avatarEl; return avatarEl;
} }
function normalizeRelationFlag(value) { function resolveLastMessagePreview(text = '') {
const clean = String(value || '').trim().toLowerCase(); const parsed = parseDmTechBlocks(String(text || ''));
if (clean === 'close_friend' || clean === 'contact') return clean; const display = String(parsed.displayText || '').trim();
return 'none'; return display || '';
}
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) { function formatChatRowTime(ts) {
@@ -160,39 +83,51 @@ function formatChatRowTime(ts) {
} }
function compareChatRows(a, b) { function compareChatRows(a, b) {
const timeA = Number(a?.lastMessageTimeMs || 0); const timeA = Number(a?.lastTimeMs || 0);
const timeB = Number(b?.lastMessageTimeMs || 0); const timeB = Number(b?.lastTimeMs || 0);
if (timeA !== timeB) return timeB - timeA; if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.peerLogin || '').toLowerCase(); const nameA = String(a?.name || '').toLowerCase();
const nameB = String(b?.peerLogin || '').toLowerCase(); const nameB = String(b?.name || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru'); return nameA.localeCompare(nameB, 'ru');
} }
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"/></svg>';
export function render({ navigate, chrome }) { export function render({ navigate, chrome }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen'; screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
const head = document.createElement('header'); const head = document.createElement('header');
head.className = 'dm-head'; head.className = 'dm-head';
head.innerHTML = ` head.innerHTML = `
<div class="dm-head-brand" aria-hidden="true"></div> <div class="dm-head-brand">
<h1 class="dm-head-title">Чаты</h1> <div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<div class="dm-head-menu-wrap"> <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">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden> <div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts"> <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"> <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> <circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path> <path d="M16 16l4 4"></path>
</svg> </svg>
<span>Поиск пользователей</span> <span>Поиск контактов</span>
</button> </button>
</div> </div>
</div> </div>
`; `;
const menuButton = head.querySelector('.dm-head-menu-btn'); const headName = head.querySelector('.dm-head-name');
menuButton?.append(createOverflowDots()); if (headName) headName.textContent = login;
const menuWrap = head.querySelector('.dm-head-menu-wrap'); const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu'); const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the // 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 // header bounds. A dropdown overflowing below it can look visible but clicks may
@@ -200,15 +135,11 @@ export function render({ navigate, chrome }) {
menuTemplate?.remove(); menuTemplate?.remove();
let menuPortal = null; let menuPortal = null;
let menuBackdrop = null;
const closeHeadMenu = () => { const closeHeadMenu = () => {
menuPortal?.remove(); menuPortal?.remove();
menuBackdrop?.remove();
menuPortal = null; menuPortal = null;
menuBackdrop = null;
menuButton?.setAttribute('aria-expanded', 'false'); menuButton?.setAttribute('aria-expanded', 'false');
menuButton?.classList.remove('menu-open-pressed');
menuWrap?.classList.remove('is-open'); menuWrap?.classList.remove('is-open');
}; };
@@ -216,16 +147,10 @@ export function render({ navigate, chrome }) {
if (!menuPortal || !menuButton) return; if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect(); const rect = menuButton.getBoundingClientRect();
const margin = 10; const margin = 10;
const menuRect = menuPortal.getBoundingClientRect(); const menuWidth = menuPortal.offsetWidth || 206;
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 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.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(top)}px`; menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
}; };
const openHeadMenu = () => { const openHeadMenu = () => {
@@ -235,7 +160,11 @@ export function render({ navigate, chrome }) {
portal.setAttribute('role', 'menu'); portal.setAttribute('role', 'menu');
portal.innerHTML = ` portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts"> <button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<span>Поиск пользователей</span> <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> </button>
`; `;
@@ -247,17 +176,11 @@ export function render({ navigate, chrome }) {
}); });
portal.addEventListener('click', (event) => event.stopPropagation()); portal.addEventListener('click', (event) => event.stopPropagation());
const backdrop = document.createElement('div'); document.body.append(portal);
backdrop.className = 'ui-menu-dim-layer';
backdrop.addEventListener('click', closeHeadMenu);
document.body.append(backdrop, portal);
menuBackdrop = backdrop;
menuPortal = portal; menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true'); menuButton.setAttribute('aria-expanded', 'true');
menuButton.classList.add('menu-open-pressed');
menuWrap?.classList.add('is-open'); menuWrap?.classList.add('is-open');
positionHeadMenu(); positionHeadMenu();
requestAnimationFrame(positionHeadMenu);
}; };
menuButton?.addEventListener('click', (event) => { menuButton?.addEventListener('click', (event) => {
@@ -283,17 +206,16 @@ export function render({ navigate, chrome }) {
window.addEventListener('resize', onMenuViewportChange, { passive: true }); window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true }); window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack dm-list'; list.className = 'stack dm-list';
function renderRow(item) { function renderRow(item) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const relationFlag = normalizeRelationFlag(item.relationFlag); const avatarEl = createDmAvatar(item.id);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin);
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.className = 'dm-av dm-av--default';
@@ -302,14 +224,14 @@ function renderRow(item) {
<div class="dm-row-main"> <div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap"> <div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong> <strong class="dm-row-title"></strong>
<span class="dm-contact-note">${relationBadge}</span> ${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
</div> </div>
<p class="dm-row-last-message"></p> <p class="dm-row-last-message"></p>
</div> </div>
<div class="dm-row-meta-col"> <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>'} ${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line"> <div class="dm-row-meta-line">
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'} ${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span> <span class="dm-chevron">${SVG_CHEVRON}</span>
</div> </div>
</div> </div>
@@ -317,15 +239,11 @@ function renderRow(item) {
const titleEl = row.querySelector('.dm-row-title'); const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message'); const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time'); const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.peerLogin || ''); if (titleEl) titleEl.textContent = String(item.name || '');
if (previewEl) previewEl.textContent = 'Загрузка…'; if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); if (timeEl) timeEl.textContent = String(item.time || '');
row.prepend(avatarWrap); row.prepend(avatarWrap);
void resolveDialogPreview(item).then((text) => { row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row; return row;
} }
@@ -339,64 +257,62 @@ function renderRow(item) {
} }
try { try {
const payload = await authService.listContacts(); const relations = await loadCurrentRelations();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; const contacts = relations.outContacts || [];
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
setContacts(contacts); setContacts(contacts);
list.innerHTML = ''; list.innerHTML = '';
const byPeer = new Map(); const contactRows = contacts.map((login) => {
dialogs.forEach((dialog) => { const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const peerLogin = String(dialog?.peerLogin || '').trim(); const canonicalLogin = normalizeDmChatId(login);
if (!peerLogin) return; const chat = getChatMessages(canonicalLogin);
const key = peerLogin.toLowerCase(); const lastChat = chat[chat.length - 1];
const relationFlag = normalizeRelationFlag(dialog?.relationFlag); const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const next = { const lastTimeMs = Number(lastChat?.createdAtMs || 0);
id: peerLogin, return {
peerLogin, id: canonicalLogin,
relationFlag, name: preview?.name || login,
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), time: formatChatRowTime(lastTimeMs),
unreadCount: Number(dialog?.unreadCount || 0), unread,
hasDialog: Boolean(dialog?.hasDialog), notInContacts: false,
lastTimeMs,
}; };
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 rows = Array.from(byPeer.values()).sort((a, b) => { const allChatIds = Object.keys(state.chats || {})
const orderA = relationOrder(a.relationFlag); .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
const orderB = relationOrder(b.relationFlag); .filter((id) => (getChatMessages(id) || []).length > 0);
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) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
empty.textContent = 'Пока нет диалогов'; empty.textContent = 'Пока нет ни контактов, ни сообщений';
list.append(empty); list.append(empty);
return; return;
} }
let dividerInserted = false; rows.forEach((item) => list.append(renderRow(item)));
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) { } catch (error) {
if (isSessionInvalidError(error)) { if (isSessionInvalidError(error)) {
list.innerHTML = ''; list.innerHTML = '';
@@ -436,7 +352,7 @@ function renderRow(item) {
} }
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(list); screen.append(divider, list);
loadList(); loadList();
screen.cleanup = () => { screen.cleanup = () => {
+25 -105
View File
@@ -1,5 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js';
@@ -216,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
let persistedCenterLogin = ''; let persistedCenterLogin = '';
let persistedCenterHistory = []; let persistedCenterHistory = [];
export function render({ navigate, route, chrome } = {}) { export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || ''); const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) { if (!keepHistory) {
@@ -283,7 +282,10 @@ export function render({ navigate, route, chrome } = {}) {
else window.history.replaceState({}, '', nextPath); else window.history.replaceState({}, '', nextPath);
} }
function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0;
}
function openSearchModal() { function openSearchModal() {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
@@ -488,119 +490,36 @@ export function render({ navigate, route, chrome } = {}) {
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred); if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
persistHistory(); persistHistory();
setBackButtonState(backBtnEl);
} catch (error) { } catch (error) {
if (requestId !== loadSeq) return; if (requestId !== loadSeq) return;
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
} }
} }
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({ const header = renderHeader({
title: 'Связи', title: 'Связи',
rightActions: [ leftAction: {
{ label: '←',
iconNode: createOverflowDots(), onClick: () => {
className: 'network-top-more-btn', if (!centerHistory.length) return;
title: 'Ещё действия', const prev = centerHistory.pop();
ariaLabel: 'Меню связей', if (!prev) {
onClick: toggleTopMenu, setBackButtonState(backBtnEl);
return;
}
void load(prev, { pushHistory: false });
}, },
},
rightActions: [
{ label: 'Найти', onClick: openSearchModal },
], ],
}); });
// «Связи» используют тот же общий topbar, что и остальные страницы. const backBtnEl = header.querySelector('.header-left .icon-btn');
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header. setBackButtonState(backBtnEl);
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 внутри). // Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => { screen.cleanup = () => {
closeTopMenu();
document.removeEventListener('click', onTopMenuOutsideClick);
document.removeEventListener('keydown', onTopMenuKeydown);
window.removeEventListener('resize', onTopMenuViewportChange);
window.removeEventListener('scroll', onTopMenuViewportChange, true);
if (engine) engine.destroy(); if (engine) engine.destroy();
engine = null; engine = null;
appScreenEl?.classList.remove('network-scroll-lock'); appScreenEl?.classList.remove('network-scroll-lock');
@@ -623,10 +542,11 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => openSearchModal(), 0); window.setTimeout(() => openSearchModal(), 0);
} }
} }
setBackButtonState(backBtnEl);
// Панель фильтров слоёв (оверлей под шапкой) // Панель фильтров слоёв (оверлей под шапкой)
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar app-top-tabs'; filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
@@ -640,8 +560,8 @@ export function render({ navigate, route, chrome } = {}) {
filterBar.append(chip); filterBar.append(chip);
}); });
chrome?.setTopbar(header); header.classList.add('network-header-overlay');
stage.append(board, filterBar); stage.append(board, header, filterBar);
screen.append(stage); screen.append(stage);
return screen; return screen;
} }
+10 -40
View File
@@ -134,7 +134,7 @@ async function enrichItem(item, activeTab) {
function renderEmpty(activeTab) { function renderEmpty(activeTab) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card stack notification-empty-state'; card.className = 'card stack';
const title = document.createElement('strong'); const title = document.createElement('strong');
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов'; title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
const text = document.createElement('p'); const text = document.createElement('p');
@@ -304,22 +304,10 @@ export function render({ navigate, chrome } = {}) {
chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div'); const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs'; tabs.className = 'tabs';
tabs.innerHTML = ` tabs.innerHTML = `
<button <button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
type="button" <button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
data-tab="replies"
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
>Ответы</button>
<button
type="button"
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
data-tab="events"
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
>События</button>
`; `;
const list = document.createElement('div'); const list = document.createElement('div');
@@ -359,31 +347,13 @@ export function render({ navigate, chrome } = {}) {
} }
} }
function setActiveNotificationTab(nextTab) { tabs.querySelectorAll('.tab-btn').forEach((btn) => {
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
state.notificationsTab = normalizedTab;
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
const selected = node.dataset.tab === normalizedTab;
node.classList.toggle('is-active', selected);
node.dataset.selected = selected ? 'true' : 'false';
node.setAttribute('aria-selected', selected ? 'true' : 'false');
});
}
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
setActiveNotificationTab(state.notificationsTab);
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies'; const nextTab = String(btn.dataset.tab || 'replies');
if (state.notificationsTab === nextTab) { if (state.notificationsTab === nextTab) return;
setActiveNotificationTab(nextTab); state.notificationsTab = nextTab;
return; tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
} btn.classList.add('active');
setActiveNotificationTab(nextTab);
void load(); void load();
}); });
}); });
+14 -98
View File
@@ -1,4 +1,4 @@
import { profile } from '../mock-data.js'; import { profile } from '../mock-data.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { import {
PROFILE_GENDER_FEMALE, PROFILE_GENDER_FEMALE,
@@ -7,7 +7,6 @@ import {
} from '../services/user-profile-params.js'; } from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js'; import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -102,105 +101,22 @@ export function render({ navigate, chrome }) {
const topbar = document.createElement('header'); const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile'; topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = ` topbar.innerHTML = `
<div class="header-left" aria-hidden="true"></div> <div class="header-actions profile-top-actions">
<div class="header-center"><h1 class="page-title">Профиль</h1></div> <button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap"> <img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"> </button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</button> </button>
</div> </div>
`; `;
const topActions = topbar.querySelector('.profile-top-actions');
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap'); topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn'); topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
profileMenuButton?.append(createOverflowDots()); topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
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');
};
const positionProfileMenu = () => {
if (!profileMenuPortal || !profileMenuButton) return;
const rect = profileMenuButton.getBoundingClientRect();
const margin = 10;
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(top)}px`;
};
const openProfileMenu = () => {
if (!profileMenuButton || profileMenuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
<span>Редактировать профиль</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
<span>Кошелёк</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
<span>Настройки</span>
</button>
`;
const goTo = (route) => {
closeProfileMenu();
navigate(route);
};
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
portal.addEventListener('click', (event) => event.stopPropagation());
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) => {
event.preventDefault();
event.stopPropagation();
if (profileMenuPortal) closeProfileMenu();
else openProfileMenu();
});
document.addEventListener('click', (event) => {
if (!profileMenuPortal) return;
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
closeProfileMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !profileMenuPortal) return;
closeProfileMenu();
profileMenuButton?.focus();
});
window.addEventListener('resize', positionProfileMenu, { passive: true });
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
chrome?.setTopbar(topbar); chrome?.setTopbar(topbar);
const card = document.createElement('div'); const card = document.createElement('div');
-509
View File
@@ -1,509 +0,0 @@
/*
* Единый визуальный язык кнопок основного приложения:
* белое содержимое, без рамок и самостоятельной подложки.
*
* Исключения:
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn),
:root a.primary-btn,
:root a.secondary-btn,
:root a.destructive-btn,
:root a.ghost-btn,
:root a.icon-btn,
:root a.text-btn {
color: #ffffff !important;
background: transparent !important;
background-image: none !important;
border: 0 !important;
box-shadow: none !important;
text-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root a.primary-btn:hover,
:root a.secondary-btn:hover,
:root a.destructive-btn:hover,
:root a.ghost-btn:hover,
:root a.icon-btn:hover,
:root a.text-btn:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
filter: brightness(1.12);
}
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
* Эффект существует только пока кнопка физически нажата.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root a.primary-btn:active,
:root a.secondary-btn:active,
:root a.destructive-btn:active,
:root a.ghost-btn:active,
:root a.icon-btn:active,
:root a.text-btn:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.12) !important;
border: 0 !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
:root a.primary-btn[aria-disabled='true'],
:root a.secondary-btn[aria-disabled='true'],
:root a.destructive-btn[aria-disabled='true'],
:root a.ghost-btn[aria-disabled='true'],
:root a.icon-btn[aria-disabled='true'],
:root a.text-btn[aria-disabled='true'] {
color: rgba(255, 255, 255, 0.42) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
}
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
* Переключатель канала исключён: его ::after является функциональным бегунком.
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
background: transparent !important;
background-image: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
*/
:root .toolbar-btn {
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
color 120ms ease;
}
:root .toolbar-btn:active {
transform: translateY(1px) scale(0.96);
background: rgba(0, 0, 0, 0.14) !important;
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
}
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root a.primary-btn:focus-visible,
:root a.secondary-btn:focus-visible,
:root a.destructive-btn:focus-visible,
:root a.ghost-btn:focus-visible,
:root a.icon-btn:focus-visible,
:root a.text-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
/* Уведомления: «Ответы / События».
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.18) !important;
border: 0 !important;
box-shadow:
inset 0 4px 11px rgba(0, 0, 0, 0.72),
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
transform: translateY(1px) scale(0.965) !important;
filter: brightness(0.88) !important;
}
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
}
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
background: rgba(0, 0, 0, 0.12) !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97) !important;
filter: brightness(0.9) !important;
}
/* Верхний toolbar отдельная цветовая роль: золотой текст и иконки.
* Это правило намеренно расположено после глобального белого button-rule. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: var(--app-topbar-gold) !important;
}
/* Верхний toolbar: вместо золотого акцента белые глифы с голубым ореолом.
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
}
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
color: #FFFFFF !important;
outline: none !important;
filter:
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
}
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
:root .dm-chat-input button.dm-emoji-btn,
:root .dm-chat-input button.dm-send-btn,
:root .dm-chat-input button.dm-edit-banner__close,
:root .dm-chat-input button.dm-emoji-btn:hover,
:root .dm-chat-input button.dm-send-btn:hover,
:root .dm-chat-input button.dm-edit-banner__close:hover,
:root .dm-chat-input button.dm-emoji-btn:focus,
:root .dm-chat-input button.dm-send-btn:focus,
:root .dm-chat-input button.dm-edit-banner__close:focus {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
}
/* ===== Dropdown menu polish: persistent overflow press + text-only glowing rows ===== */
/* Menu rows stay visually transparent in every interaction state. The menu panel itself
* remains translucent, while each action is represented only by its glowing label. */
:root .dm-head-menu-item,
:root .dm-head-menu-item:hover,
:root .dm-head-menu-item:focus,
:root .dm-head-menu-item:focus-visible,
:root .dm-head-menu-item:active,
:root .channel-menu-item,
:root .channel-menu-item:hover,
:root .channel-menu-item:focus,
:root .channel-menu-item:focus-visible,
:root .channel-menu-item:active,
:root .channel-menu-item.destructive,
:root .channel-menu-item.destructive:hover,
:root .channel-menu-item.destructive:focus-visible,
:root .channel-menu-item.destructive:active {
color: #F7FBFF !important;
background: transparent !important;
background-image: none !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
transform: none !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.34)) !important;
}
/* Hover/focus is expressed only by a slightly stronger glyph glow, never a row plate. */
:root .dm-head-menu-item:hover,
:root .dm-head-menu-item:focus-visible,
:root .channel-menu-item:hover,
:root .channel-menu-item:focus-visible {
color: #FFFFFF !important;
text-shadow:
0 0 6px rgba(110, 205, 255, 0.86),
0 0 15px rgba(72, 145, 255, 0.46) !important;
filter: drop-shadow(0 0 5px rgba(92, 190, 255, 0.46)) !important;
}
/* Keep the overflow trigger visibly pressed for exactly as long as its menu is open. */
:root .topbar-slot .dm-head-menu-btn[aria-expanded='true'],
:root .topbar-slot .channels-top-more-btn[aria-expanded='true'],
:root .topbar-slot .network-top-more-btn[aria-expanded='true'],
:root .network-header-overlay .network-top-more-btn[aria-expanded='true'] {
color: #FFFFFF !important;
background: rgba(0, 0, 0, 0.12) !important;
border: 0 !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97) !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
}
/* ===== Authoritative open-menu trigger state (2026-08-23) =====
* A dedicated JS class, not :active/:focus, owns the pressed state while a menu is open.
* Kept at the very end of the stylesheet so older compatibility rules cannot cancel it. */
:root .dm-head-menu-btn.menu-open-pressed,
:root .channels-top-more-btn.menu-open-pressed,
:root .network-top-more-btn.menu-open-pressed,
:root .topbar-slot .dm-head-menu-btn.menu-open-pressed,
:root .topbar-slot .channels-top-more-btn.menu-open-pressed,
:root .topbar-slot .network-top-more-btn.menu-open-pressed,
:root .network-header-overlay .network-top-more-btn.menu-open-pressed {
color: #FFFFFF !important;
background: rgba(0, 0, 0, 0.14) !important;
border: 0 !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.09) !important;
transform: translateY(1px) scale(0.97) !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.76),
0 0 12px rgba(72, 145, 255, 0.38) !important;
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
animation: none !important;
}
/* ===== Final dropdown behavior: truly transparent menu + persistent pressed trigger =====
* The dropdown itself has no visual surface. Only the global dim layer affects content
* behind it. The overflow trigger gets its own inset pseudo-layer so the pressed state
* cannot be cancelled by the generic button reset after pointer release. */
:root .dm-head-menu,
:root .dm-head-menu--portal,
:root .channel-menu-wrap,
:root .channel-menu-wrap--portal {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
:root .dm-head-menu::before,
:root .dm-head-menu::after,
:root .channel-menu-wrap::before,
:root .channel-menu-wrap::after,
:root .channel-menu-wrap--portal::before,
:root .channel-menu-wrap--portal::after {
content: none !important;
display: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* No separator or hover plate: the dropdown is literally labels over faded content. */
:root .channel-menu-divider {
display: none !important;
}
:root .dm-head-menu-item,
:root .dm-head-menu-item:hover,
:root .dm-head-menu-item:focus,
:root .dm-head-menu-item:focus-visible,
:root .dm-head-menu-item:active,
:root .channel-menu-item,
:root .channel-menu-item:hover,
:root .channel-menu-item:focus,
:root .channel-menu-item:focus-visible,
:root .channel-menu-item:active,
:root .channel-menu-item.destructive,
:root .channel-menu-item.destructive:hover,
:root .channel-menu-item.destructive:focus-visible,
:root .channel-menu-item.destructive:active {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
}
/* Keep the channels backdrop out of the toolbar itself, matching the other pages. */
:root .channels-menu-overlay {
top: calc(var(--topbar-height, 64px) + var(--call-minimized-bar-height, 0px)) !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
background: rgba(0, 0, 0, 0.49) !important;
}
/* Authoritative pressed well. It is independent from :active/:focus and survives until
* JS removes menu-open-pressed / aria-expanded=true on actual menu close. */
:root .dm-head-menu-btn,
:root .channels-top-more-btn,
:root .network-top-more-btn {
position: relative !important;
isolation: isolate;
}
:root .dm-head-menu-btn.menu-open-pressed::before,
:root .channels-top-more-btn.menu-open-pressed::before,
:root .network-top-more-btn.menu-open-pressed::before,
:root .dm-head-menu-btn[aria-expanded='true']::before,
:root .channels-top-more-btn[aria-expanded='true']::before,
:root .network-top-more-btn[aria-expanded='true']::before {
content: '' !important;
display: block !important;
position: absolute !important;
inset: 2px !important;
z-index: 0 !important;
pointer-events: none !important;
border: 0 !important;
border-radius: 10px !important;
background: rgba(0, 0, 0, 0.18) !important;
background-image: none !important;
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.72),
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
:root .dm-head-menu-btn.menu-open-pressed,
:root .channels-top-more-btn.menu-open-pressed,
:root .network-top-more-btn.menu-open-pressed,
:root .dm-head-menu-btn[aria-expanded='true'],
:root .channels-top-more-btn[aria-expanded='true'],
:root .network-top-more-btn[aria-expanded='true'] {
background: transparent !important;
box-shadow: none !important;
transform: translateY(1px) scale(0.97) !important;
filter: brightness(0.90) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
animation: none !important;
}
:root .dm-head-menu-btn.menu-open-pressed > *,
:root .channels-top-more-btn.menu-open-pressed > *,
:root .network-top-more-btn.menu-open-pressed > *,
:root .dm-head-menu-btn[aria-expanded='true'] > *,
:root .channels-top-more-btn[aria-expanded='true'] > *,
:root .network-top-more-btn[aria-expanded='true'] > * {
position: relative;
z-index: 1;
}
/* ===== Definitive open overflow state + lighter menu dim (2026-08-23) =====
* The generic button reset above has specificity 0,4,1, so the persistent
* menu state must beat it directly on the real button (not on ::before).
* Keep this visually identical to the physical :active press until JS closes the menu. */
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.12) !important;
background-image: none !important;
border: 0 !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97) !important;
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
animation: none !important;
}
/* The menu itself is still fully transparent; only the content below is dimmed.
* Previous alpha was ~0.49. Reduce it by about 20% relatively -> ~0.39. */
:root body .ui-menu-dim-layer,
:root body .dm-head-menu-backdrop,
:root body .channels-menu-overlay {
background: rgba(0, 0, 0, 0.39) !important;
}
/* ===== Round persistent overflow press + subtle background depth (2026-08-23) =====
* The open overflow control is a circular recessed well. While a topbar menu is
* present, only the content plane below the toolbar recedes slightly; the toolbar
* and the fully transparent menu stay at full size. */
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
border-radius: 50% !important;
}
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed::before,
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true']::before,
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed::before,
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true']::before,
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed::before,
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true']::before,
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed::before,
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true']::before {
border-radius: 50% !important;
}
/* Depth cue: the feed/graph plane gently recedes while a topbar overflow menu is open.
* Keep the scale small enough that opening the menu does not feel like a page jump. */
@supports selector(body:has(*)) {
:root body .app-shell > .screen-content {
transform-origin: 50% 50%;
transition: transform 180ms cubic-bezier(0.22, 0.61, 0.36, 1), filter 180ms ease;
}
:root body:has(> .ui-menu-dim-layer) .app-shell > .screen-content,
:root body:has(> #modal-root #channels-top-menu-overlay) .app-shell > .screen-content {
transform: scale(0.975);
filter: brightness(0.97) saturate(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
:root body .app-shell > .screen-content {
transition: none !important;
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -34,8 +34,8 @@ body::before {
--toolbar-height: 78px; --toolbar-height: 78px;
--keyboard-offset: 0px; --keyboard-offset: 0px;
background: transparent; background: transparent;
border-left: 0; border-left: 1px solid rgba(211, 170, 86, 0.2);
border-right: 0; border-right: 1px solid rgba(211, 170, 86, 0.2);
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
} }
+2 -2
View File
@@ -198,7 +198,7 @@
} }
.fg-orb-host .fg-pngorb-init { .fg-orb-host .fg-pngorb-init {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px; background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
} }
.fg-node.is-family .node-dot { .fg-node.is-family .node-dot {
@@ -422,7 +422,7 @@
/* Панель фильтров слоёв (оверлей под шапкой) */ /* Панель фильтров слоёв (оверлей под шапкой) */
.fg-filter-bar { .fg-filter-bar {
position: absolute; position: absolute;
top: max(72px, calc(env(safe-area-inset-top) + 68px)); top: max(54px, calc(env(safe-area-inset-top) + 50px));
left: 0; left: 0;
right: 0; right: 0;
z-index: 11; z-index: 11;