Compare commits

..
26 changed files with 516 additions and 1582 deletions
@@ -74,10 +74,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
+ escapePart(valueText) + '|'
+ valueNum;
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
}
DbController db = DbController.getInstance();
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
@@ -95,6 +91,14 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
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(
login,
settingType,
+3
View File
@@ -23,6 +23,9 @@
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
---
+2 -1
View File
@@ -13,7 +13,8 @@
- `value_num = number of messages already seen in channel`;
- `value_text = ''`.
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
## 2. Структура записи
+2 -2
View File
@@ -12,13 +12,13 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260822132200';
window.__SHINE_BUILD_HASH__ = '20260819190000';
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', './styles/buttons-white.css'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
+6 -29
View File
@@ -5,7 +5,6 @@ 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';
@@ -81,17 +80,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?v=202608221218';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608221218';
import * as chatView from './pages/chat-view.js?v=202608191738';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
import * as 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?v=202608221226';
import * as notificationsView from './pages/notifications-view.js?v=202608221226';
import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -191,15 +190,6 @@ 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',
@@ -1138,14 +1128,6 @@ 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();
@@ -1182,12 +1164,7 @@ function renderApp() {
}
screenEl.append(screen);
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
currentCleanup = () => {
pageCleanup?.();
scrollToBottomControl?.cleanup();
};
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
+10 -33
View File
@@ -25,28 +25,27 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
return (cleanLogin[0] || '?').toUpperCase();
}
export function renderAvatar({
initials = '?',
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
avatar = null,
size = 'large',
className = '',
title = '',
alt = 'Аватар',
glow = false,
} = {}) {
const wrap = document.createElement('div');
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
const classes = ['avatar', 'avatar-image'];
const sizeClass = pickSizeClass(size);
if (sizeClass) classes.add(sizeClass);
if (sizeClass) classes.push(sizeClass);
const extraClass = String(className || '').trim();
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 (extraClass) classes.push(...extraClass.split(/\s+/g));
wrap.className = classes.join(' ');
if (title) wrap.title = String(title);
const fallback = document.createElement('span');
fallback.className = 'avatar-fallback';
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
wrap.append(fallback);
const txId = String(avatar?.ar || '').trim();
@@ -57,8 +56,7 @@ export function renderAvatar({
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
const img = document.createElement('img');
img.className = 'avatar-photo';
img.alt = String(alt || 'Аватар');
img.alt = 'Аватар';
img.loading = 'lazy';
img.decoding = 'async';
wrap.append(img);
@@ -133,24 +131,3 @@ export function renderAvatar({
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,
});
}
+8 -21
View File
@@ -1,20 +1,14 @@
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
const wrap = document.createElement('header');
wrap.className = 'page-header app-topbar-shell';
wrap.className = 'page-header';
const left = document.createElement('div');
left.className = 'header-left';
if (leftAction) {
const btn = document.createElement('button');
btn.type = 'button';
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.className = 'icon-btn';
btn.textContent = leftAction.label;
btn.addEventListener('click', leftAction.onClick);
left.append(btn);
}
@@ -25,16 +19,9 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
left.append(label);
}
const center = document.createElement('div');
center.className = 'header-center';
if (centerNode instanceof Node) {
center.append(centerNode);
} else {
const h1 = document.createElement('h1');
h1.className = 'page-title';
h1.textContent = title;
center.append(h1);
}
const h1 = document.createElement('h1');
h1.className = 'page-title';
h1.textContent = title;
const right = document.createElement('div');
right.className = 'header-actions';
@@ -53,6 +40,6 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
right.append(btn);
});
wrap.append(left, center, right);
wrap.append(left, h1, right);
return wrap;
}
-10
View File
@@ -1,10 +0,0 @@
export function createOverflowDots({ className = '' } = {}) {
const dots = document.createElement('span');
const extra = String(className || '').trim();
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
dots.setAttribute('aria-hidden', 'true');
for (let i = 0; i < 3; i += 1) {
dots.append(document.createElement('i'));
}
return dots;
}
@@ -30,7 +30,6 @@ 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);
@@ -43,7 +42,6 @@ export function attachScrollToBottomButton({
const hide = () => {
button.classList.remove('is-visible');
button.setAttribute('aria-hidden', 'true');
button.tabIndex = -1;
};
const scheduleRefresh = () => {
@@ -105,7 +103,6 @@ export function attachScrollToBottomButton({
button.classList.toggle('is-visible', shouldShow);
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
button.tabIndex = shouldShow ? 0 : -1;
}
const scrollToBottom = () => {
+11 -10
View File
@@ -10,7 +10,7 @@ import {
openArweaveAttachmentManager,
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { renderAvatar } from '../components/avatar-image.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
@@ -40,16 +40,17 @@ function normalizeMetaText(value, max, label) {
function renderAvatarPreview(slot, avatar, title) {
if (!slot) return;
slot.innerHTML = '';
const label = String(title || 'К').trim() || 'К';
const wrap = renderAvatar({
initials: label.slice(0, 1).toUpperCase() || 'К',
avatar,
size: 'small',
className: 'channel-profile-avatar',
title: label,
alt: 'Аватар канала',
});
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
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);
}
+1 -3
View File
@@ -6,7 +6,6 @@ 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';
@@ -74,14 +73,13 @@ 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';
+25 -16
View File
@@ -1,4 +1,5 @@
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';
@@ -1143,27 +1144,33 @@ 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 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 scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
const header = renderHeader({
centerNode: threadHeaderButton,
title: '',
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
rightActions: [],
});
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');
@@ -1349,6 +1356,7 @@ export function render({ navigate, route, chrome }) {
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
screen.cleanup = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
@@ -1537,6 +1545,7 @@ export function render({ navigate, route, chrome }) {
})();
screen.cleanup = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean');
};
+248 -42
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
import {
authService,
getMessageReactionState,
@@ -16,7 +17,7 @@ import {
showToast,
softHaptic,
} from '../services/channels-ux.js';
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
@@ -31,6 +32,7 @@ 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: 'Канал' };
@@ -236,9 +238,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;
@@ -386,17 +579,18 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
}
function createChannelAvatarElement(channel, size = 72) {
const txId = String(channel?.avaAr || '').trim();
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: 'Аватар канала',
});
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() || 'К';
}
return wrap;
}
@@ -1445,6 +1639,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 +1942,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 +2110,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 +2121,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 +2139,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 +2161,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 +2219,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) {
@@ -2039,6 +2257,7 @@ 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';
@@ -2054,20 +2273,19 @@ 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({
centerNode: channelHeaderButton,
title: '',
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;
@@ -2317,19 +2535,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 = () => {
@@ -2485,6 +2690,7 @@ export function render({ navigate, route, chrome }) {
})();
screen.cleanup = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
+64 -71
View File
@@ -1,4 +1,4 @@
import { channels as mockChannels } from '../mock-data.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { 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 { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { navigateBack } from '../router.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -90,16 +90,6 @@ 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 [
@@ -895,6 +885,7 @@ 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>
@@ -903,7 +894,6 @@ 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'),
@@ -949,6 +939,7 @@ function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onSubscribeChannel,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
@@ -960,7 +951,7 @@ function openTopChannelsMenu({
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
const estimatedHeight = 320;
let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
@@ -977,12 +968,14 @@ 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) => {
@@ -1213,11 +1206,19 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible);
const avatar = createChannelAvatar(channel);
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (channel.avaAr) {
const img = document.createElement('img');
img.alt = '';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
avatar.append(img);
} else {
avatar.textContent = channel.avatar;
}
const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
@@ -1228,38 +1229,11 @@ 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) : '';
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);
if (unreadCount > 0) {
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
controls.append(count);
}
controls.append(time, count);
controls.append(time);
row.append(avatar, main, controls);
row.addEventListener('click', () => {
@@ -1276,14 +1250,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);
@@ -1371,18 +1337,43 @@ 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.append(createOverflowDots());
topMenuBtn.textContent = '⋮';
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
@@ -1391,14 +1382,18 @@ 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 }),
}),
});
});
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
topBarLeft.append(backBtn, topTitle);
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
topBarEl.append(topBarLeft, topBarRight);
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
@@ -1416,21 +1411,19 @@ export function render({ navigate, route, chrome }) {
});
topTitle.textContent = channelsViewTitle(listState.viewMode);
findChannelBtn.style.display = '';
createInMyBtn.style.display = '';
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
};
chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
screen.append(contentEl);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
+12 -54
View File
@@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
@@ -29,55 +28,10 @@ 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 '';
@@ -953,6 +907,10 @@ 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;
@@ -966,9 +924,9 @@ export function render({ navigate, route, chrome }) {
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId);
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
chrome?.setTopbar(
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
@@ -979,7 +937,7 @@ export function render({ navigate, route, chrome }) {
onClick: () => handleStartCall('audio'),
},
{
iconNode: createOverflowDots(),
label: '⋮',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -1036,9 +994,8 @@ export function render({ navigate, route, chrome }) {
},
},
],
});
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
chrome?.setTopbar(chatHeader);
}),
);
if (!isKnownContact) {
const card = document.createElement('div');
@@ -1606,6 +1563,7 @@ 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);
+16 -12
View File
@@ -9,7 +9,6 @@ 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';
@@ -38,14 +37,13 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
function createDmAvatar(login, { className = '' } = {}) {
function createDmAvatar(login) {
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) => {
@@ -60,7 +58,6 @@ function createDmAvatar(login, { className = '' } = {}) {
: null,
size: 'small',
title,
className,
});
upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded);
@@ -94,6 +91,8 @@ 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';
@@ -103,11 +102,15 @@ export function render({ navigate, chrome }) {
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand">
<div class="dm-head-avatar-slot"></div>
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<div class="dm-head-menu-wrap">
<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">
@@ -120,14 +123,11 @@ export function render({ navigate, chrome }) {
</div>
</div>
`;
const headAvatarSlot = head.querySelector('.dm-head-avatar-slot');
if (headAvatarSlot) {
headAvatarSlot.append(createDmAvatar(login, { className: 'dm-head-avatar' }));
}
const menuButton = head.querySelector('.dm-head-menu-btn');
menuButton?.append(createOverflowDots());
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
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,6 +206,9 @@ 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';
@@ -229,6 +232,7 @@ 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>
`;
@@ -348,7 +352,7 @@ export function render({ navigate, chrome }) {
}
chrome?.setTopbar(head);
screen.append(list);
screen.append(divider, list);
loadList();
screen.cleanup = () => {
+24 -5
View File
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
let persistedCenterLogin = '';
let persistedCenterHistory = [];
export function render({ navigate, route, chrome } = {}) {
export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) {
@@ -282,7 +282,10 @@ export function render({ navigate, route, chrome } = {}) {
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');
@@ -487,6 +490,7 @@ export function render({ navigate, route, chrome } = {}) {
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
persistHistory();
setBackButtonState(backBtnEl);
} catch (error) {
if (requestId !== loadSeq) return;
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
@@ -495,10 +499,24 @@ export function render({ navigate, route, chrome } = {}) {
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);
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
@@ -524,10 +542,11 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => openSearchModal(), 0);
}
}
setBackButtonState(backBtnEl);
// Панель фильтров слоёв (оверлей под шапкой)
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar app-top-tabs';
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
@@ -541,8 +560,8 @@ export function render({ navigate, route, chrome } = {}) {
filterBar.append(chip);
});
chrome?.setTopbar(header);
stage.append(board, filterBar);
header.classList.add('network-header-overlay');
stage.append(board, header, filterBar);
screen.append(stage);
return screen;
}
+9 -39
View File
@@ -304,22 +304,10 @@ export function render({ navigate, chrome } = {}) {
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
tabs.className = 'tabs';
tabs.innerHTML = `
<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>
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
`;
const list = document.createElement('div');
@@ -359,31 +347,13 @@ export function render({ navigate, chrome } = {}) {
}
}
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) => {
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
if (state.notificationsTab === nextTab) {
setActiveNotificationTab(nextTab);
return;
}
setActiveNotificationTab(nextTab);
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');
void load();
});
});
+1 -4
View File
@@ -7,7 +7,6 @@ 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: 'Профиль' };
@@ -102,17 +101,15 @@ 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 = () => {
+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);
+8
View File
@@ -925,6 +925,14 @@ export async function refreshSessions() {
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() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
-223
View File
@@ -1,223 +0,0 @@
/*
* Единый визуальный язык кнопок основного приложения:
* белое содержимое, без рамок и самостоятельной подложки.
*
* Исключения:
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn),
:root a.primary-btn,
:root a.secondary-btn,
:root a.destructive-btn,
:root a.ghost-btn,
:root a.icon-btn,
:root a.text-btn {
color: #ffffff !important;
background: transparent !important;
background-image: none !important;
border: 0 !important;
box-shadow: none !important;
text-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root a.primary-btn:hover,
:root a.secondary-btn:hover,
:root a.destructive-btn:hover,
:root a.ghost-btn:hover,
:root a.icon-btn:hover,
:root a.text-btn:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
filter: brightness(1.12);
}
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
* Эффект существует только пока кнопка физически нажата.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root a.primary-btn:active,
:root a.secondary-btn:active,
:root a.destructive-btn:active,
:root a.ghost-btn:active,
:root a.icon-btn:active,
:root a.text-btn:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.12) !important;
border: 0 !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
:root a.primary-btn[aria-disabled='true'],
:root a.secondary-btn[aria-disabled='true'],
:root a.destructive-btn[aria-disabled='true'],
:root a.ghost-btn[aria-disabled='true'],
:root a.icon-btn[aria-disabled='true'],
:root a.text-btn[aria-disabled='true'] {
color: rgba(255, 255, 255, 0.42) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
}
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
* Переключатель канала исключён: его ::after является функциональным бегунком.
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
background: transparent !important;
background-image: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
*/
:root .toolbar-btn {
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
color 120ms ease;
}
:root .toolbar-btn:active {
transform: translateY(1px) scale(0.96);
background: rgba(0, 0, 0, 0.14) !important;
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
}
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root a.primary-btn:focus-visible,
:root a.secondary-btn:focus-visible,
:root a.destructive-btn:focus-visible,
:root a.ghost-btn:focus-visible,
:root a.icon-btn:focus-visible,
:root a.text-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
/* Уведомления: «Ответы / События».
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.18) !important;
border: 0 !important;
box-shadow:
inset 0 4px 11px rgba(0, 0, 0, 0.72),
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
transform: translateY(1px) scale(0.965) !important;
filter: brightness(0.88) !important;
}
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
}
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
background: rgba(0, 0, 0, 0.12) !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97) !important;
filter: brightness(0.9) !important;
}
/* Верхний toolbar отдельная цветовая роль: золотой текст и иконки.
* Это правило намеренно расположено после глобального белого button-rule. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: var(--app-topbar-gold) !important;
}
/* Верхний toolbar: вместо золотого акцента белые глифы с голубым ореолом.
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
}
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
color: #FFFFFF !important;
outline: none !important;
filter:
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
}
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
:root .dm-chat-input button.dm-emoji-btn,
:root .dm-chat-input button.dm-send-btn,
:root .dm-chat-input button.dm-edit-banner__close,
:root .dm-chat-input button.dm-emoji-btn:hover,
:root .dm-chat-input button.dm-send-btn:hover,
:root .dm-chat-input button.dm-edit-banner__close:hover,
:root .dm-chat-input button.dm-emoji-btn:focus,
:root .dm-chat-input button.dm-send-btn:focus,
:root .dm-chat-input button.dm-edit-banner__close:focus {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -198,7 +198,7 @@
}
.fg-orb-host .fg-pngorb-init {
display: flex; align-items: center; justify-content: center;
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
}
.fg-node.is-family .node-dot {
@@ -422,7 +422,7 @@
/* Панель фильтров слоёв (оверлей под шапкой) */
.fg-filter-bar {
position: absolute;
top: max(72px, calc(env(safe-area-inset-top) + 68px));
top: max(54px, calc(env(safe-area-inset-top) + 50px));
left: 0;
right: 0;
z-index: 11;