Слить main с актуальными UI-изменениями

This commit is contained in:
2026-08-22 16:36:47 +03:00
10 changed files with 306 additions and 52 deletions
+226 -24
View File
@@ -236,9 +236,200 @@ function buildThreadRoute(messageRef, selector) {
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(ownerBlockchainName || '').trim();
const name = String(channelName || '').trim();
if (!ownerBch || !name) return '';
return `${ownerBch}/${name}`;
}
function getChannelScrollRoot() {
return document.getElementById('app-screen');
}
function scrollRootBy(delta, smooth = false) {
const root = getChannelScrollRoot();
const behavior = smooth ? 'smooth' : 'auto';
if (root && typeof root.scrollBy === 'function') {
root.scrollBy({ top: delta, behavior });
return;
}
window.scrollBy({ top: delta, behavior });
}
function getUnreadAnchorViewportFraction(unreadCount = 0) {
const count = Math.max(0, Number(unreadCount || 0));
if (count <= 1) return 0.68;
if (count <= 3) return 0.56;
if (count <= 7) return 0.48;
return 0.42;
}
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
if (!element) return false;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
const rect = element.getBoundingClientRect();
const delta = rect.top - targetTop;
if (Math.abs(delta) < 2) return true;
scrollRootBy(delta, smooth);
return true;
}
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
return scrollElementToViewportFraction(
screen.querySelector('.channel-unread-line'),
getUnreadAnchorViewportFraction(unreadCount),
smooth,
);
}
function createChannelReadTracker({
screen,
routeKey,
settingKey,
unreadCount,
messagesCount,
initialSeenCount,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const canWrite = !!(settingKey && login && storagePwd);
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let inFlight = false;
let disposed = false;
let rafId = 0;
let timerId = 0;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
};
const queueFlush = (delayMs = 180) => {
if (disposed || !canWrite) return;
clearTimer();
timerId = setTimeout(() => {
timerId = 0;
void flush();
}, Math.max(0, Number(delayMs) || 0));
};
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return;
if (inFlight) {
queueFlush(120);
return;
}
inFlight = true;
try {
await authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd,
});
persistedSeenCount = next;
} catch {
queueFlush(800);
} finally {
inFlight = false;
}
};
const collectSeenCount = () => {
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
if (!cards.length) return safeInitialSeenCount;
if (!unreadLine) return safeMessagesCount;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
let seen = safeInitialSeenCount;
for (const card of cards) {
const localNumber = Number(card.dataset.localNumber || 0);
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
const rect = card.getBoundingClientRect();
if (rect.top > thresholdTop + 1) break;
seen = Math.max(seen, localNumber);
}
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
};
const measure = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = collectSeenCount();
if (next > desiredSeenCount) {
desiredSeenCount = next;
queueFlush(180);
}
});
};
const scrollRoot = getChannelScrollRoot();
const onScroll = () => measure();
const onResize = () => measure();
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
} else {
window.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
if (initialSyncRequired) {
void authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: safeMessagesCount,
storagePwd,
}).catch(() => {});
persistedSeenCount = safeMessagesCount;
desiredSeenCount = safeMessagesCount;
} else {
window.setTimeout(() => measure(), 120);
}
const cleanup = () => {
disposed = true;
clearTimer();
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
scrollRoot.removeEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
};
return {
cleanup,
measure,
};
}
function firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
@@ -1445,6 +1636,7 @@ async function loadFromApi(route, channelId) {
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(),
@@ -1747,6 +1939,9 @@ function renderPostCard(post, {
if (refKey) {
card.dataset.messageKey = refKey;
}
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
card.dataset.localNumber = String(Number(post.localNumber));
}
card.classList.add('is-counters-visible');
if (!post.messageRef || !selector) return card;
@@ -1912,6 +2107,10 @@ function renderPostCard(post, {
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
const readCount = Math.max(0, messagesCount - unreadCount);
if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta';
@@ -1919,13 +2118,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(reverseWarning);
}
if (Number(channelData.unreadCount || 0) > 0) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
screen.append(unreadLine);
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
@@ -1944,6 +2136,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const postsByKey = new Map();
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
.map((event) => mapChannelMetaEvent(event, channelData.channel));
let unreadLineInserted = unreadCount === 0;
const feedItems = [
...metaEvents.map((event) => ({
type: 'meta',
@@ -1965,6 +2158,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (feedItems.length) {
feedItems.forEach((item) => {
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = 'Не прочитанные сообщения';
feed.append(unreadLine);
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
return;
@@ -2016,10 +2216,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
return () => {
// noop
};
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
unreadCount,
messagesCount,
initialSeenCount: readCount,
});
return tracker.cleanup;
}
function renderSkeleton(screen) {
@@ -2317,19 +2532,6 @@ export function render({ navigate, route, chrome }) {
try {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
void authService.upsertUserSetting({
login: state.session.login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: lastSeenCount,
storagePwd: state.session.storagePwdInMemory,
}).catch(() => {});
}
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
+6 -19
View File
@@ -1217,7 +1217,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
@@ -1228,7 +1227,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const count = document.createElement('span');
count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0);
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
if (unreadCount > 0) {
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
controls.append(count);
}
count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
@@ -1259,7 +1261,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
});
controls.append(menuButton);
}
controls.append(time, count);
controls.append(time);
row.append(avatar, main, controls);
row.addEventListener('click', () => {
@@ -1276,14 +1278,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list);
}
function updateBottomCta({ button }) {
if (!button) return;
button.hidden = true;
button.textContent = '';
button.className = 'channels-bottom-action';
button.onclick = null;
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
@@ -1397,9 +1391,6 @@ export function render({ navigate, route, chrome }) {
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
@@ -1418,19 +1409,15 @@ export function render({ navigate, route, chrome }) {
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
screen.append(contentEl);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
+8 -2
View File
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
@@ -426,7 +426,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
form,
actions,
+12 -2
View File
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -102,7 +103,10 @@ export function render({ navigate }) {
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
cancelButton.addEventListener('click', () => {
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
@@ -190,7 +194,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Сохранение ключей',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
card,
actions,
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
replacement.addEventListener('click', () => {
stageClosed = true;
stopTimers();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
replacement.addEventListener('click', () => {
loginCompleted = true;
stopAutoLogin();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);