SHA256
Compare commits
6
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b |
+2
-2
@@ -12,13 +12,13 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
+29
-6
@@ -5,6 +5,7 @@ import {
|
||||
syncTrackedRouteHistory,
|
||||
} from './router.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 { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
@@ -80,17 +81,17 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202608191738';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -190,6 +191,15 @@ let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
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([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
@@ -1128,6 +1138,14 @@ function renderPageFailureFallback(pageId, error) {
|
||||
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() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
@@ -1164,7 +1182,12 @@ function renderApp() {
|
||||
}
|
||||
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
const pageCleanup = 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('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
|
||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
if (sizeClass) classes.add(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||
if (glow) classes.add('avatar-glow');
|
||||
wrap.className = Array.from(classes).join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.className = 'avatar-photo';
|
||||
img.alt = String(alt || 'Аватар');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
wrap.append(img);
|
||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header';
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
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);
|
||||
left.append(btn);
|
||||
}
|
||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function attachScrollToBottomButton({
|
||||
button.className = 'scroll-to-bottom-btn';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||
button.tabIndex = -1;
|
||||
button.append(buildArrowIcon());
|
||||
target.append(button);
|
||||
|
||||
@@ -42,6 +43,7 @@ export function attachScrollToBottomButton({
|
||||
const hide = () => {
|
||||
button.classList.remove('is-visible');
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.tabIndex = -1;
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
@@ -103,6 +105,7 @@ export function attachScrollToBottomButton({
|
||||
|
||||
button.classList.toggle('is-visible', shouldShow);
|
||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||
button.tabIndex = shouldShow ? 0 : -1;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
openArweaveAttachmentManager,
|
||||
markArweaveAttachmentPlaced,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
const label = String(title || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readArweaveAttachmentHistory,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
@@ -73,13 +74,14 @@ export function render({ navigate }) {
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<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>
|
||||
<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="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -1144,33 +1143,27 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
||||
|
||||
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({
|
||||
title: '',
|
||||
centerNode: threadHeaderButton,
|
||||
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');
|
||||
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);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
@@ -1356,7 +1349,6 @@ export function render({ navigate, route, chrome }) {
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
@@ -1545,7 +1537,6 @@ export function render({ navigate, route, chrome }) {
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
@@ -579,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
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() || 'К';
|
||||
}
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
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`);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -2257,7 +2254,6 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.className = 'stack channels-screen channels-screen--channel';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
@@ -2273,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
|
||||
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({
|
||||
title: '',
|
||||
centerNode: channelHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Канал: ...', className: 'channel-header-route-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');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
@@ -2690,7 +2687,6 @@ export function render({ navigate, route, chrome }) {
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
};
|
||||
|
||||
@@ -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 { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
||||
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() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
const row = document.createElement('article');
|
||||
row.className = 'channel-row';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||||
<div class="channel-row-main">
|
||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.prepend(createChannelAvatar(channel));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onSubscribeChannel,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 320;
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
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_OWNED)) },
|
||||
{ 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) => {
|
||||
@@ -1206,16 +1213,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
|
||||
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 avatar = createChannelAvatar(channel);
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
@@ -1233,6 +1231,36 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
controls.append(count);
|
||||
}
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
if (!isGuest) {
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.type = 'button';
|
||||
menuButton.className = 'channel-menu-trigger';
|
||||
menuButton.append(createOverflowDots());
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(menuButton);
|
||||
listState.revealedCounters.add(channel.id);
|
||||
|
||||
if (listState.openMenuId === channel.id) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
return;
|
||||
}
|
||||
|
||||
listState.openMenuId = channel.id;
|
||||
openChannelMenu({
|
||||
listState,
|
||||
channel,
|
||||
anchorEl: menuButton,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
||||
rerenderList,
|
||||
});
|
||||
rerenderList();
|
||||
});
|
||||
controls.append(menuButton);
|
||||
}
|
||||
controls.append(time);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
@@ -1337,43 +1365,18 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
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');
|
||||
topTitle.className = 'channels-top-title';
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
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');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.textContent = '⋮';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
@@ -1382,18 +1385,11 @@ export function render({ navigate, route, chrome }) {
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
onSubscribeChannel: () => openSimpleSubscribeModal({
|
||||
kind: 'channel',
|
||||
kindLabel: 'Добавить канал',
|
||||
submitLabel: 'Добавить',
|
||||
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
topBarLeft.append(backBtn, topTitle);
|
||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topBarRight);
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
@@ -1411,10 +1407,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
findChannelBtn.style.display = '';
|
||||
createInMyBtn.style.display = '';
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -28,10 +29,55 @@ import {
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.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: 'Чат' };
|
||||
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) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
@@ -907,10 +953,6 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
const scrollToBottomControl = attachScrollToBottomButton({
|
||||
scrollContainer: () => boundScrollContainer || wrap,
|
||||
});
|
||||
|
||||
const historyLoader = document.createElement('div');
|
||||
historyLoader.className = 'dm-history-loader';
|
||||
historyLoader.hidden = true;
|
||||
@@ -924,9 +966,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
{
|
||||
@@ -937,7 +979,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onClick: () => handleStartCall('audio'),
|
||||
},
|
||||
{
|
||||
label: '⋮',
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
@@ -994,8 +1036,9 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
@@ -1563,7 +1606,6 @@ export function render({ navigate, route, chrome }) {
|
||||
void loadHistoryPage({ preserveScroll: true });
|
||||
});
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
stopAllTwemojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
@@ -37,13 +38,14 @@ async function loadDmAvatarSnapshot(login) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createDmAvatar(login) {
|
||||
function createDmAvatar(login, { className = '' } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||
@@ -58,6 +60,7 @@ function createDmAvatar(login) {
|
||||
: null,
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
upgraded.classList.add('avatar');
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -91,26 +94,16 @@ function compareChatRows(a, b) {
|
||||
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 }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const login = String(state.session.login || '').trim();
|
||||
|
||||
const head = document.createElement('header');
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand">
|
||||
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||
<div class="dm-head-id">
|
||||
<span class="dm-head-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dm-head-brand" aria-hidden="true"></div>
|
||||
<h1 class="dm-head-title">Контакты</h1>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
|
||||
<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>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||
@@ -123,11 +116,10 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const headName = head.querySelector('.dm-head-name');
|
||||
if (headName) headName.textContent = login;
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||
@@ -206,9 +198,6 @@ export function render({ navigate, chrome }) {
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack dm-list';
|
||||
|
||||
@@ -232,7 +221,6 @@ export function render({ navigate, chrome }) {
|
||||
${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">
|
||||
${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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -352,7 +340,7 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(divider, list);
|
||||
screen.append(list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
|
||||
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
||||
let persistedCenterLogin = '';
|
||||
let persistedCenterHistory = [];
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
|
||||
else window.history.replaceState({}, '', nextPath);
|
||||
}
|
||||
|
||||
function setBackButtonState(backBtn) {
|
||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||
backBtn.disabled = centerHistory.length === 0;
|
||||
}
|
||||
|
||||
|
||||
function openSearchModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
if (requestId !== loadSeq) return;
|
||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (!centerHistory.length) return;
|
||||
const prev = centerHistory.pop();
|
||||
if (!prev) {
|
||||
setBackButtonState(backBtnEl);
|
||||
return;
|
||||
}
|
||||
void load(prev, { pushHistory: false });
|
||||
},
|
||||
},
|
||||
rightActions: [
|
||||
{ label: 'Найти', onClick: openSearchModal },
|
||||
],
|
||||
});
|
||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||
setBackButtonState(backBtnEl);
|
||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||
header.classList.add('network-topbar');
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
|
||||
window.setTimeout(() => openSearchModal(), 0);
|
||||
}
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
|
||||
filterBar.append(chip);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header, filterBar);
|
||||
chrome?.setTopbar(header);
|
||||
stage.append(board, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ async function enrichItem(item, activeTab) {
|
||||
|
||||
function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
@@ -304,10 +304,22 @@ export function render({ navigate, chrome } = {}) {
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.innerHTML = `
|
||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||
<button
|
||||
type="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');
|
||||
@@ -347,13 +359,31 @@ export function render({ navigate, chrome } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
function setActiveNotificationTab(nextTab) {
|
||||
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', () => {
|
||||
const nextTab = String(btn.dataset.tab || 'replies');
|
||||
if (state.notificationsTab === nextTab) return;
|
||||
state.notificationsTab = nextTab;
|
||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||
if (state.notificationsTab === nextTab) {
|
||||
setActiveNotificationTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNotificationTab(nextTab);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -101,15 +102,17 @@ export function render({ navigate, chrome }) {
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-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>
|
||||
`;
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Единый визуальный язык кнопок основного приложения:
|
||||
* белое содержимое, без рамок и самостоятельной подложки.
|
||||
*
|
||||
* Исключения:
|
||||
* - фильтры групп на экране «Связи» (.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;
|
||||
}
|
||||
+1413
-3
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@
|
||||
}
|
||||
.fg-orb-host .fg-pngorb-init {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
||||
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||
}
|
||||
|
||||
.fg-node.is-family .node-dot {
|
||||
@@ -422,7 +422,7 @@
|
||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||
.fg-filter-bar {
|
||||
position: absolute;
|
||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
||||
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
|
||||
Reference in New Issue
Block a user