SHA256
Compare commits
1
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
97f90167f1 |
+4
-8
@@ -74,6 +74,10 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
+ escapePart(valueText) + '|'
|
+ escapePart(valueText) + '|'
|
||||||
+ valueNum;
|
+ valueNum;
|
||||||
|
|
||||||
|
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
|
||||||
|
}
|
||||||
|
|
||||||
DbController db = DbController.getInstance();
|
DbController db = DbController.getInstance();
|
||||||
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
||||||
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
||||||
@@ -91,14 +95,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
|
|
||||||
if (!signatureOk) {
|
|
||||||
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
|
|
||||||
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
|
|
||||||
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
|
|
||||||
login, settingType, settingKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
UserSettingEntry entry = new UserSettingEntry(
|
UserSettingEntry entry = new UserSettingEntry(
|
||||||
login,
|
login,
|
||||||
settingType,
|
settingType,
|
||||||
|
|||||||
@@ -23,9 +23,6 @@
|
|||||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||||
|
|
||||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
>
|
|
||||||
> `unreadCount` для канала считается по `user_settings`:
|
|
||||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
- `value_num = number of messages already seen in channel`;
|
- `value_num = number of messages already seen in channel`;
|
||||||
- `value_text = ''`.
|
- `value_text = ''`.
|
||||||
|
|
||||||
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
|
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
|
||||||
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
|
|
||||||
|
|
||||||
## 2. Структура записи
|
## 2. Структура записи
|
||||||
|
|
||||||
|
|||||||
@@ -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 { renderHeader } from '../components/header.js';
|
||||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.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,7 +1143,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack channels-screen channels-screen--thread';
|
screen.className = 'stack channels-screen channels-screen--thread';
|
||||||
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 scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -1182,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 });
|
||||||
@@ -1355,10 +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 = () => {
|
|
||||||
scrollToBottomControl.cleanup();
|
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
|
||||||
};
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1545,7 +1538,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
scrollToBottomControl.cleanup();
|
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
getMessageReactionState,
|
getMessageReactionState,
|
||||||
@@ -238,200 +237,9 @@ function buildThreadRoute(messageRef, selector) {
|
|||||||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||||
const name = String(channelName || '').trim();
|
const name = String(channelName || '').trim();
|
||||||
if (!ownerBch || !name) return '';
|
|
||||||
return `${ownerBch}/${name}`;
|
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) {
|
function firstNonEmptyText(...candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate !== 'string') continue;
|
if (typeof candidate !== 'string') continue;
|
||||||
@@ -1639,7 +1447,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
|
|
||||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: String(payload.channel?.channelDescription || '').trim(),
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
@@ -1942,9 +1749,6 @@ function renderPostCard(post, {
|
|||||||
if (refKey) {
|
if (refKey) {
|
||||||
card.dataset.messageKey = 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');
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
if (!post.messageRef || !selector) return card;
|
if (!post.messageRef || !selector) return card;
|
||||||
@@ -2110,10 +1914,6 @@ function renderPostCard(post, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
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) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
reverseWarning.className = 'channel-head-meta';
|
reverseWarning.className = 'channel-head-meta';
|
||||||
@@ -2121,6 +1921,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
screen.append(reverseWarning);
|
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');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.className = 'destructive-btn channel-main-action';
|
actionButton.className = 'destructive-btn channel-main-action';
|
||||||
actionButton.textContent = 'Подписаться на канал';
|
actionButton.textContent = 'Подписаться на канал';
|
||||||
@@ -2139,7 +1946,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const postsByKey = new Map();
|
const postsByKey = new Map();
|
||||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||||
let unreadLineInserted = unreadCount === 0;
|
|
||||||
const feedItems = [
|
const feedItems = [
|
||||||
...metaEvents.map((event) => ({
|
...metaEvents.map((event) => ({
|
||||||
type: 'meta',
|
type: 'meta',
|
||||||
@@ -2161,13 +1967,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
|
|
||||||
if (feedItems.length) {
|
if (feedItems.length) {
|
||||||
feedItems.forEach((item) => {
|
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') {
|
if (item.type === 'meta') {
|
||||||
feed.append(renderChannelMetaEventCard(item.event));
|
feed.append(renderChannelMetaEventCard(item.event));
|
||||||
return;
|
return;
|
||||||
@@ -2219,25 +2018,10 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
screen.append(feed, backButton);
|
screen.append(feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
|
||||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
return () => {
|
||||||
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
|
// noop
|
||||||
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) {
|
function renderSkeleton(screen) {
|
||||||
@@ -2257,7 +2041,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack channels-screen channels-screen--channel';
|
screen.className = 'stack channels-screen channels-screen--channel';
|
||||||
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 scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||||
@@ -2296,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;
|
||||||
@@ -2535,6 +2317,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
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 titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||||
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
||||||
const openEntrypointHistory = () => {
|
const openEntrypointHistory = () => {
|
||||||
@@ -2690,7 +2485,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
scrollToBottomControl.cleanup();
|
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1219,6 +1219,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
|
|
||||||
const main = renderChannelMain(channel);
|
const main = renderChannelMain(channel);
|
||||||
|
|
||||||
|
const isGuest = !state.session.isAuthorized;
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
@@ -1229,11 +1230,38 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
if (unreadCount > 0) {
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
controls.append(count);
|
|
||||||
|
if (!isGuest) {
|
||||||
|
const menuButton = document.createElement('button');
|
||||||
|
menuButton.type = 'button';
|
||||||
|
menuButton.className = 'channel-menu-trigger';
|
||||||
|
menuButton.textContent = '…';
|
||||||
|
menuButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(menuButton);
|
||||||
|
listState.revealedCounters.add(channel.id);
|
||||||
|
|
||||||
|
if (listState.openMenuId === channel.id) {
|
||||||
|
closeChannelMenu(listState);
|
||||||
|
rerenderList();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
controls.append(time);
|
|
||||||
|
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, count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -1250,6 +1278,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.append(list);
|
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 }) {
|
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
renderSkeletonList(contentEl, 5);
|
renderSkeletonList(contentEl, 5);
|
||||||
@@ -1395,6 +1431,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
|
|
||||||
|
const bottomCta = document.createElement('button');
|
||||||
|
bottomCta.type = 'button';
|
||||||
|
|
||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
@@ -1415,15 +1454,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
createInMyBtn.style.display = '';
|
createInMyBtn.style.display = '';
|
||||||
topMenuBtn.style.display = '';
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
|
|
||||||
|
updateBottomCta({ button: bottomCta });
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome?.setTopbar(topBarEl);
|
chrome?.setTopbar(topBarEl);
|
||||||
screen.append(contentEl);
|
screen.append(contentEl, bottomCta);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBottomCta({ button: bottomCta });
|
||||||
|
|
||||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
rerenderList();
|
rerenderList();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -907,9 +906,6 @@ 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 scrollToBottomControl = attachScrollToBottomButton({
|
|
||||||
scrollContainer: () => boundScrollContainer || wrap,
|
|
||||||
});
|
|
||||||
|
|
||||||
const historyLoader = document.createElement('div');
|
const historyLoader = document.createElement('div');
|
||||||
historyLoader.className = 'dm-history-loader';
|
historyLoader.className = 'dm-history-loader';
|
||||||
@@ -1563,7 +1559,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
void loadHistoryPage({ preserveScroll: true });
|
void loadHistoryPage({ preserveScroll: true });
|
||||||
});
|
});
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
scrollToBottomControl.cleanup();
|
|
||||||
hideUnreadSeparator({ rerender: false });
|
hideUnreadSeparator({ rerender: false });
|
||||||
stopAllTwemojiAnimations();
|
stopAllTwemojiAnimations();
|
||||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
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 { state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
@@ -101,90 +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-actions profile-head-menu-wrap dm-head-menu-wrap">
|
<div class="header-actions profile-top-actions">
|
||||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||||
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
<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>
|
</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'));
|
||||||
let profileMenuPortal = null;
|
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||||
|
|
||||||
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 });
|
|
||||||
|
|
||||||
chrome?.setTopbar(topbar);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
|
import { authService, clearAuthMessages, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
checkLoginExistsOnSolana,
|
checkLoginExistsOnSolana,
|
||||||
@@ -426,13 +426,7 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Зарегистрироваться',
|
title: 'Зарегистрироваться',
|
||||||
leftAction: {
|
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||||
label: '←',
|
|
||||||
onClick: () => {
|
|
||||||
resetRegistrationFlow();
|
|
||||||
navigate('start-view');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
form,
|
form,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
resetRegistrationFlow,
|
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -103,10 +102,7 @@ export function render({ navigate }) {
|
|||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => {
|
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||||
resetRegistrationFlow();
|
|
||||||
navigate('start-view');
|
|
||||||
});
|
|
||||||
|
|
||||||
const okButton = document.createElement('button');
|
const okButton = document.createElement('button');
|
||||||
okButton.className = 'primary-btn';
|
okButton.className = 'primary-btn';
|
||||||
@@ -194,13 +190,7 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: {
|
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||||
label: '←',
|
|
||||||
onClick: () => {
|
|
||||||
resetRegistrationFlow();
|
|
||||||
navigate('start-view');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
card,
|
card,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
resetRegistrationFlow,
|
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -554,7 +553,6 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
stageClosed = true;
|
stageClosed = true;
|
||||||
stopTimers();
|
stopTimers();
|
||||||
resetRegistrationFlow();
|
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
@@ -659,7 +657,6 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
loginCompleted = true;
|
loginCompleted = true;
|
||||||
stopAutoLogin();
|
stopAutoLogin();
|
||||||
resetRegistrationFlow();
|
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
|
|||||||
@@ -925,14 +925,6 @@ export async function refreshSessions() {
|
|||||||
return state.sessions;
|
return state.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetRegistrationFlow() {
|
|
||||||
const next = createInitialState();
|
|
||||||
state.registrationDraft = next.registrationDraft;
|
|
||||||
state.registrationHelp = next.registrationHelp;
|
|
||||||
state.registrationPayment = next.registrationPayment;
|
|
||||||
state.keyStorage = next.keyStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetStateForSignedOut() {
|
function resetStateForSignedOut() {
|
||||||
const next = createInitialState({ withStoredSession: false });
|
const next = createInitialState({ withStoredSession: false });
|
||||||
state.chats = next.chats;
|
state.chats = next.chats;
|
||||||
|
|||||||
@@ -4248,36 +4248,6 @@ textarea.input {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-unread-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 10px 0 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(244, 202, 102, 0.46);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9));
|
|
||||||
color: #ffe6a7;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 226, 155, 0.08), 0 10px 24px rgba(6, 10, 20, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-unread-line::before,
|
|
||||||
.channel-unread-line::after {
|
|
||||||
content: '';
|
|
||||||
flex: 1 1 0;
|
|
||||||
height: 1px;
|
|
||||||
min-width: 18px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-screen--channel .channel-feed {
|
.channels-screen--channel .channel-feed {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: -7px;
|
margin-left: -7px;
|
||||||
|
|||||||
Reference in New Issue
Block a user