SHA256
Compare commits
1
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
97f90167f1 |
@@ -1,141 +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.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');
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||
import { renderHeader } from '../components/header.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,7 +1143,6 @@ 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 header = renderHeader({
|
||||
title: '',
|
||||
@@ -1182,7 +1180,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
@@ -1355,10 +1352,6 @@ export function render({ navigate, route, chrome }) {
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1545,7 +1538,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 { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -2042,7 +2041,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';
|
||||
@@ -2081,7 +2079,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
};
|
||||
let activeSelector = null;
|
||||
@@ -2488,7 +2485,6 @@ export function render({ navigate, route, chrome }) {
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
scrollToBottomControl.cleanup();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -907,9 +906,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';
|
||||
@@ -1563,7 +1559,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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
@@ -101,90 +101,22 @@ 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-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>
|
||||
<div class="header-actions profile-top-actions">
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}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">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
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());
|
||||
|
||||
document.body.append(portal);
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
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 });
|
||||
|
||||
const topActions = topbar.querySelector('.profile-top-actions');
|
||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
|
||||
Reference in New Issue
Block a user