Обновить UI профиля и навигации

This commit is contained in:
2026-09-08 16:13:04 +03:00
parent 023d61a1e9
commit 7d27bfdcaf
90 changed files with 14931 additions and 13565 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
@@ -259,7 +259,7 @@ function createPasswordModal() {
};
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -427,7 +427,7 @@ export function render({ navigate }) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = items.map((item) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
@${escapeHtml(item.login)}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
</button>`
@@ -633,11 +633,11 @@ export function render({ navigate }) {
}
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Сервер доступа',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
introCard,
listCard,
addCard,
+6 -8
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -78,16 +78,14 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
back: { label: '<', onClick: () => navigate('channels-list') },
}));
const form = document.createElement('form');
form.className = 'card stack';
@@ -97,7 +95,7 @@ export function render({ navigate }) {
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
<button type="button" class="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
@@ -47,16 +47,14 @@ function createDebounced(fn, delayMs = 240) {
};
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Новый персональный публичный чат',
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}),
);
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}));
const form = document.createElement('form');
form.className = 'card stack';
@@ -119,7 +117,7 @@ export function render({ navigate }) {
rows.slice(0, 8).forEach((login) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = String(login);
btn.addEventListener('click', () => {
selectedCanonicalLogin = String(login);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
@@ -11,16 +11,14 @@ function formatTime(ts) {
}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
const controls = document.createElement('div');
controls.className = 'card row';
+45 -57
View File
@@ -7,16 +7,12 @@ import {
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
function closeUploadsMenu(controls) {
const menu = controls?.querySelector?.('[data-menu="true"]');
if (menu) menu.hidden = true;
}
function setStatusBadge(node, status, label) {
if (!node) return;
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
@@ -64,25 +60,10 @@ function renderTile(item, index) {
return tile;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack arweave-uploads-screen';
const controls = document.createElement('div');
controls.className = 'arweave-uploads-toolbar';
controls.innerHTML = `
<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>
<div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
<button class="shine-btn shine-btn--settings" 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';
@@ -91,7 +72,6 @@ export function render({ navigate }) {
const uploadFile = async () => {
statusLine.textContent = '';
closeUploadsMenu(controls);
try {
await openArweaveAttachmentManager({
login: state.session.login,
@@ -134,42 +114,50 @@ export function render({ navigate }) {
}, 0);
}
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
void uploadFile();
const topbar = createTopBar({
title: 'Загрузка файлов в блокчейн',
back: { onClick: () => navigate('settings-view') },
actions: [
{
label: '+',
title: 'Добавить файл',
ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add',
onClick: () => { void uploadFile(); },
},
{
iconNode: createOverflowDots(),
title: 'Меню',
ariaLabel: 'Меню загрузок',
className: 'arweave-uploads-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Добавить файл', action: () => uploadFile() },
{
label: 'Очистить историю',
action: () => {
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
},
},
{
label: 'Справка',
action: () => window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
),
},
],
},
},
],
});
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
event.stopPropagation();
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = !menu.hidden;
});
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
});
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
);
});
screen.addEventListener('click', (event) => {
if (controls.contains(event.target)) return;
closeUploadsMenu(controls);
});
screen.append(controls, statusLine, list);
chrome?.setTopbar(topbar);
screen.append(statusLine, list);
renderHistory();
return screen;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { navigateBack } from '../router.js';
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
@@ -63,9 +63,9 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = renderHeader({
const topbar = createTopBar({
title: 'О канале',
leftAction: {
back: {
label: '←',
onClick: () => {
if (window.history.length > 1) {
+130 -65
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.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';
@@ -23,7 +23,7 @@ import {
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -437,7 +437,7 @@ function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, l
};
}
function openBlockchainDetailsModal(details) {
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -471,6 +471,7 @@ function openBlockchainDetailsModal(details) {
});
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
@@ -495,7 +496,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -508,7 +509,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -569,9 +570,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
@@ -589,10 +592,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -602,7 +606,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
if (textEl) textEl.focus();
}
function openRepostModal({ navigate, channels = [], onSubmit }) {
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -668,8 +672,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
@@ -716,7 +722,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -749,16 +755,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
@@ -798,7 +808,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
authorTile.className = 'ui-button channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
@@ -821,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -844,7 +854,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -865,7 +875,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.className = 'ui-button deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${author}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -915,7 +925,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item thread-like-btn';
likeButton.className = 'ui-button channel-action-item thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
@@ -949,7 +959,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item thread-reply-btn';
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -962,6 +972,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue),
isActive: handlers.isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
@@ -969,7 +980,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item thread-share-btn';
shareButton.className = 'ui-button channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
@@ -987,7 +998,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
originalButton.className = 'channel-action-item';
originalButton.className = 'ui-button channel-action-item';
originalButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
@@ -1009,7 +1020,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -1025,13 +1036,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
raw: node,
localNumber,
msgSubType,
}));
}), { isActive: handlers.isActive });
});
actions.append(detailsButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.className = 'ui-button channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -1086,11 +1097,12 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
return wrap;
}
function applyPendingScroll(screen, routeKey) {
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
const target = pendingThreadScroll.get(routeKey);
if (!target) return;
const doScroll = () => {
if (!shouldContinue()) return;
if (target === '__LAST_REPLY__') {
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
const last = cards[cards.length - 1];
@@ -1108,7 +1120,7 @@ function applyPendingScroll(screen, routeKey) {
}
};
setTimeout(doScroll, 20);
return window.setTimeout(doScroll, 20);
}
function renderSkeleton(screen) {
@@ -1120,15 +1132,18 @@ function renderSkeleton(screen) {
}
export function render({ navigate, route, chrome }) {
const selector = parseThreadSelector(route);
let selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
let activeResolvedChannelLabel = channelDisplayName;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
const refreshTimers = new Set();
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
@@ -1136,10 +1151,10 @@ export function render({ navigate, route, chrome }) {
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
const header = renderHeader({
centerNode: threadHeaderButton,
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
@@ -1156,19 +1171,12 @@ export function render({ navigate, route, chrome }) {
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const rerender = () => {
try {
const current = document.querySelector('section.channels-screen--thread');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
}
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
};
const showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
@@ -1191,6 +1199,7 @@ export function render({ navigate, route, chrome }) {
const handlers = {
navigate,
isActive: () => !disposed,
onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
@@ -1208,12 +1217,14 @@ export function render({ navigate, route, chrome }) {
await authService.addBlockLike({ login, storagePwd, message: target });
}
if (disposed) return;
setMessageReactionState(target, nextReaction);
softHaptic(10);
rerender();
void refresh();
} catch (error) {
if (disposed) return;
setMessageReactionState(target, previousReaction || 'unliked');
rerender();
void refresh();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -1222,24 +1233,27 @@ export function render({ navigate, route, chrome }) {
onReply: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
ensureActive();
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Ответ отправлен');
showStatus('');
rerender();
void refresh();
},
onRating: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
ensureActive();
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Оценка отправлена');
showStatus('');
rerender();
void refresh();
},
onRepost: async (target) => {
const { login, storagePwd } = requireSigningSession();
const feed = await authService.listSubscriptionsFeed(login, 1000);
if (disposed) return;
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
.map((row) => {
const selectorRow = {
@@ -1264,6 +1278,7 @@ export function render({ navigate, route, chrome }) {
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
@@ -1272,6 +1287,7 @@ export function render({ navigate, route, chrome }) {
message: target,
text,
});
ensureActive();
softHaptic(12);
showToast('Репост опубликован');
showStatus('');
@@ -1287,6 +1303,7 @@ export function render({ navigate, route, chrome }) {
text: 'Сообщение из треда SHiNE',
url: buildAbsoluteRouteUrl(routePath),
});
if (disposed) return;
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'copied' || result === 'shared') softHaptic(10);
@@ -1320,33 +1337,69 @@ export function render({ navigate, route, chrome }) {
isChannelPost: meta?.isChannelPost === true,
channel: selector?.channel || null,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
showStatus('');
rerender();
void refresh();
},
};
screen.append(statusBox);
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
}
const clearContent = () => {
for (const timerId of refreshTimers) window.clearTimeout(timerId);
refreshTimers.clear();
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
const skeleton = renderSkeleton(screen);
const clearOwnedModal = () => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return;
if (modalRoot.querySelector([
'#thread-blockchain-details-modal',
'#thread-edit-modal',
'#thread-history-modal',
'#thread-reply-modal',
'#thread-repost-modal',
].join(','))) {
modalRoot.innerHTML = '';
}
};
const trackTimer = (timerId) => {
if (timerId) refreshTimers.add(timerId);
return timerId;
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null;
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
return;
}
const skeleton = renderSkeleton(screen);
(async () => {
try {
let resolvedMessage = selector.message;
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
if (disposed || seq !== refreshSeq) return;
const allRows = [
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
@@ -1369,6 +1422,7 @@ export function render({ navigate, route, chrome }) {
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
try {
const ownerUser = await authService.getUser(ownerRaw);
if (disposed || seq !== refreshSeq) return;
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
if (ownerBch) {
channel = allRows.find((item) => (
@@ -1383,6 +1437,7 @@ export function render({ navigate, route, chrome }) {
if (!channel && ownerLoginFromBch) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
if (disposed || seq !== refreshSeq) return;
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
@@ -1412,6 +1467,7 @@ export function render({ navigate, route, chrome }) {
}
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
@@ -1437,6 +1493,7 @@ export function render({ navigate, route, chrome }) {
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
if (disposed || seq !== refreshSeq) return;
}
activeResolvedChannelLabel = resolvedChannelLabel;
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
@@ -1453,10 +1510,10 @@ export function render({ navigate, route, chrome }) {
};
}
let seq = 0;
let localSeq = 0;
const nextNumber = () => {
seq += 1;
return seq;
localSeq += 1;
return localSeq;
};
let ancestorsWrap = null;
@@ -1506,25 +1563,33 @@ export function render({ navigate, route, chrome }) {
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
applyPendingScroll(screen, routeKey);
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
setTimeout(() => {
trackTimer(window.setTimeout(() => {
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 20);
}, 20));
}
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
screen.append(failed);
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
};
void refresh();
return screen;
}
+216 -95
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
getMessageReactionState,
@@ -34,7 +34,7 @@ import {
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -775,7 +775,7 @@ function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, ra
};
}
function openBlockchainDetailsModal(details) {
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -809,6 +809,7 @@ function openBlockchainDetailsModal(details) {
});
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
@@ -824,7 +825,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -837,7 +838,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -898,9 +899,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
@@ -918,10 +921,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -931,7 +935,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
if (textEl) textEl.focus();
}
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
@@ -973,8 +977,10 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit(String(textEl?.value || '').trim());
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
}
@@ -984,7 +990,7 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
if (textEl) textEl.focus();
}
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActive = () => true }) {
const root = document.getElementById('modal-root');
const rows = (Array.isArray(options) ? options : [])
.map((item, index) => `
@@ -1017,6 +1023,7 @@ function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
const option = options[idx];
if (!option) return;
close();
if (!isActive()) return;
await onSelect(option);
});
});
@@ -1042,8 +1049,12 @@ function flashAndScrollToMessage(messageRef) {
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
window.setTimeout(() => {
if (target.isConnected) target.classList.add('is-focus-flash');
}, 60);
window.setTimeout(() => {
if (target.isConnected) target.classList.remove('is-focus-flash');
}, 1800);
return true;
}
@@ -1097,7 +1108,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
const parsed = parseMessageAttachments(post.body);
const item = document.createElement('button');
item.type = 'button';
item.className = 'entrypoint-history-item';
item.className = 'ui-button entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
@@ -1116,7 +1127,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
});
}
function openRepostModal({ navigate, channels = [], onSubmit }) {
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -1180,8 +1191,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
@@ -1190,7 +1203,7 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate }) {
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
@@ -1258,9 +1271,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
@@ -1278,10 +1293,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -1327,7 +1343,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
@@ -1361,16 +1377,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
@@ -1726,7 +1746,7 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
}
};
setTimeout(doScroll, 20);
return window.setTimeout(doScroll, 20);
}
function mapChannelMetaEvent(event, fallbackChannel) {
@@ -1782,6 +1802,7 @@ function renderPostCard(post, {
onRepost,
onShare,
onEdit,
isActive = () => true,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
@@ -1792,7 +1813,7 @@ function renderPostCard(post, {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
authorTile.className = 'ui-button channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin);
@@ -1821,7 +1842,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -1841,7 +1862,7 @@ function renderPostCard(post, {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -1875,7 +1896,7 @@ function renderPostCard(post, {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.className = 'ui-button deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -1946,7 +1967,7 @@ function renderPostCard(post, {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like';
likeButton.className = 'ui-button channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
@@ -1972,7 +1993,7 @@ function renderPostCard(post, {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.className = 'ui-button channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -1985,6 +2006,7 @@ function renderPostCard(post, {
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
@@ -1994,7 +2016,7 @@ function renderPostCard(post, {
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item channel-action-share';
shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
@@ -2011,7 +2033,7 @@ function renderPostCard(post, {
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
originalBtn.className = 'channel-action-item';
originalBtn.className = 'ui-button channel-action-item';
originalBtn.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
@@ -2033,7 +2055,7 @@ function renderPostCard(post, {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -2049,13 +2071,13 @@ function renderPostCard(post, {
raw: post.rawMessage,
localNumber: post.localNumber,
msgSubType: post.msgSubType,
}));
}), { isActive });
});
actions.append(detailsButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.className = 'ui-button channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -2155,6 +2177,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
isActive: handlers.isActive,
});
const key = messageRefKey(item.post.messageRef);
if (key) {
@@ -2192,10 +2215,10 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}
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 pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
: 0;
const tracker = createChannelReadTracker({
screen,
@@ -2209,7 +2232,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
initialSeenCount: readCount,
});
return tracker.cleanup;
return () => {
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
if (unreadScrollTimer) window.clearTimeout(unreadScrollTimer);
tracker.cleanup();
};
}
function renderSkeleton(screen) {
@@ -2227,14 +2254,22 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
let cleanupSeenTracking = null;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран канала уже закрыт.');
};
const showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
@@ -2244,36 +2279,79 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = '';
};
let activeChannelData = null;
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,
leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [
const header = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
{
label: '⋯',
title: 'Действия канала',
ariaLabel: 'Открыть меню канала',
className: 'channel-header-more-btn',
menu: {
minWidth: 210,
items: () => {
const apiData = activeChannelData;
if (!apiData) return [];
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
const items = [
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
];
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
items.push({
label: 'Отписаться от канала',
danger: true,
action: async () => {
try {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
showToast('Вы отписались от канала');
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
},
});
}
return items;
},
},
},
],
});
header.classList.add('channel-view-topbar');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
const channelEntrypointButton = header.querySelector('.topbar__right .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.topbar__right .channel-header-more-btn');
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
chrome?.setTopbar(header);
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
};
let activeSelector = null;
const requireSigningSession = () => {
@@ -2305,12 +2383,14 @@ export function render({ navigate, route, chrome }) {
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
if (disposed) return;
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
rerender();
void refresh();
} catch (error) {
if (disposed) return;
setMessageReactionState(messageRef, previousReaction || 'unliked');
rerender();
void refresh();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -2320,25 +2400,27 @@ export function render({ navigate, route, chrome }) {
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Ответ отправлен');
rerender();
void refresh();
};
const onRating = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Оценка отправлена');
rerender();
void refresh();
};
const onStatusAction = async (post) => {
@@ -2348,10 +2430,12 @@ export function render({ navigate, route, chrome }) {
openStatusActionMenuModal({
targetLabel: typeMeta.label,
options,
isActive: () => !disposed,
onSelect: async (option) => {
openStatusActionCommentModal({
title: option.modalTitle,
submitLabel: option.label,
isActive: () => !disposed,
onSubmit: async (text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockStatusAction({
@@ -2361,9 +2445,10 @@ export function render({ navigate, route, chrome }) {
text,
statusSubType: option.subType,
});
ensureActive();
softHaptic(14);
showToast(`${option.label} сохранено`);
rerender();
void refresh();
},
});
},
@@ -2403,10 +2488,12 @@ export function render({ navigate, route, chrome }) {
const onRepost = async (messageRef) => {
const { login, storagePwd } = requireSigningSession();
const channels = await loadOwnedChannelsForRepost(login);
if (disposed) return;
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
@@ -2415,9 +2502,10 @@ export function render({ navigate, route, chrome }) {
message: messageRef,
text,
});
ensureActive();
if (isSameChannelSelector(channel, activeSelector)) {
pendingScrollByRoute.set(routeKey, '__LAST__');
rerender();
void refresh();
}
softHaptic(12);
showToast('Репост опубликован');
@@ -2434,6 +2522,7 @@ export function render({ navigate, route, chrome }) {
text: 'Тред из канала SHiNE',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (disposed) return;
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
@@ -2455,11 +2544,12 @@ export function render({ navigate, route, chrome }) {
text: bodyText,
msgSubType,
});
ensureActive();
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
rerender();
void refresh();
};
const onEditPost = async (messageRef, text) => {
@@ -2476,9 +2566,10 @@ export function render({ navigate, route, chrome }) {
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
rerender();
void refresh();
};
const onEditChannelMeta = async ({ title, description, avatar }) => {
@@ -2494,21 +2585,65 @@ export function render({ navigate, route, chrome }) {
description,
avatar,
});
ensureActive();
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
softHaptic(12);
showToast('Профиль канала обновлён');
rerender();
void refresh();
};
screen.append(statusBox);
const skeleton = renderSkeleton(screen);
const clearContent = () => {
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null;
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
let cleanupSeenTracking = null;
const clearOwnedModal = () => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return;
const ownedSelector = [
'#about-channel-modal',
'#blockchain-details-modal',
'#channel-entrypoint-history-modal',
'#channel-entrypoint-menu-modal',
'#channel-message-modal',
'#channel-status-action-modal',
'#channel-status-menu-modal',
'#edit-channel-modal',
'#edit-message-modal',
'#message-history-modal',
'#reply-modal',
'#repost-modal',
].join(',');
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
activeChannelData = null;
activeSelector = null;
showStatus('');
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
channelHeaderButton.onclick = null;
if (channelMoreButton) channelMoreButton.disabled = true;
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
channelEntrypointButton.onclick = null;
}
const skeleton = renderSkeleton(screen);
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
if (disposed || seq !== refreshSeq) return;
activeSelector = apiData?.selector || null;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
@@ -2536,35 +2671,9 @@ export function render({ navigate, route, chrome }) {
if (aboutRoute) navigate(aboutRoute);
};
}
activeChannelData = apiData;
if (channelMoreButton) {
channelMoreButton.disabled = false;
channelMoreButton.onclick = (event) => {
event.stopPropagation();
header.querySelector('.channel-header-more-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'channel-header-more-menu';
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
about.onclick = () => {
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
menu.remove(); if (aboutRoute) navigate(aboutRoute);
};
menu.append(about);
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
unfollow.onclick = async () => {
menu.remove();
try {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
};
menu.append(unfollow);
}
header.append(menu);
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
};
}
if (channelEntrypointButton) {
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
@@ -2579,10 +2688,12 @@ export function render({ navigate, route, chrome }) {
}
skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
isActive: () => !disposed,
onAddMessage: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
@@ -2672,31 +2783,41 @@ export function render({ navigate, route, chrome }) {
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: false,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
softHaptic(15);
showToast('Подписка на канал выполнена');
rerender();
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
},
});
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error);
return;
}
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), () => {
void refresh();
});
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
};
void refresh();
return screen;
}
+26 -293
View File
@@ -16,13 +16,12 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
@@ -311,7 +310,7 @@ function renderSuggestions(container, values, onPick) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -581,7 +580,7 @@ function openChannelFinderModal({ navigate }) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -998,252 +997,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
container.append(list);
}
function closeChannelMenu(listState, clearOpenMenuId = true) {
if (typeof listState.menuCleanup === 'function') {
listState.menuCleanup();
}
listState.menuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
if (clearOpenMenuId) {
listState.openMenuId = null;
}
}
function closeTopChannelsMenu(listState) {
if (typeof listState.topMenuCleanup === 'function') {
listState.topMenuCleanup();
}
listState.topMenuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
}
function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
const titleAnchor = document.querySelector('.channels-filter-title');
const titleRect = titleAnchor?.getBoundingClientRect?.();
let top = (titleRect?.bottom || rect.bottom) + 7;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = TOP_MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const items = [
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
];
items.forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
divider.style.margin = '6px 0';
menu.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-menu-item';
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
btn.addEventListener('click', () => {
closeTopChannelsMenu(listState);
item.action?.();
});
menu.append(btn);
});
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
};
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === anchorEl) return;
closeTopChannelsMenu(listState);
};
const onWindowResize = () => closeTopChannelsMenu(listState);
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
listState.topMenuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
};
}
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
closeChannelMenu(listState, false);
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 210;
let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const canToggleSubscription = !channel.isOwnChannel;
const actionBtn = document.createElement('button');
actionBtn.type = 'button';
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
const actionLabel = document.createElement('span');
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
if (canToggleSubscription) {
actionLabel.textContent = channel.pending
? 'Выполняется...'
: channel.isSubscribed
? 'Отписаться'
: 'Подписаться';
actionBtn.disabled = !!channel.pending;
actionBtn.addEventListener('click', async (event) => {
event.stopPropagation();
if (channel.pending) return;
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
return;
}
channel.pending = true;
actionBtn.disabled = true;
actionLabel.textContent = 'Выполняется...';
const nextSubscribed = !channel.isSubscribed;
try {
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: channel.ownerBlockchainName,
targetBlockNumber: channel.channelRootBlockNumber,
targetBlockHashHex: channel.channelRootBlockHash,
unfollow: !nextSubscribed,
});
channel.isSubscribed = nextSubscribed;
channel.pending = false;
softHaptic(15);
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
closeChannelMenu(listState);
await refreshFeed();
} catch (error) {
channel.pending = false;
actionBtn.disabled = false;
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
rerenderList();
}
});
} else {
actionLabel.textContent = 'Собственный канал';
actionBtn.disabled = true;
}
const toggleWrap = document.createElement('div');
toggleWrap.className = 'channel-menu-toggle';
const toggleLabel = document.createElement('span');
toggleLabel.className = 'channel-menu-toggle-label';
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
toggleBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(toggleBtn);
channel.notificationsEnabled = !channel.notificationsEnabled;
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
listState.notificationsState = next;
writeChannelNotificationsState(next);
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
softHaptic(10);
});
toggleWrap.append(toggleLabel, toggleBtn);
menu.append(actionBtn, toggleWrap);
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) {
closeChannelMenu(listState);
rerenderList();
}
};
const onWindowResize = () => {
closeChannelMenu(listState);
rerenderList();
};
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
listState.menuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
};
}
function renderChannelMain(channel) {
const main = document.createElement('div');
main.className = 'channel-row-main';
@@ -1350,7 +1103,6 @@ function updateBottomCta({ button }) {
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
@@ -1411,39 +1163,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const listState = {
openMenuId: null,
topMenuCleanup: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
menuCleanup: null,
viewMode: normalizeChannelsViewMode(route),
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar';
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
align: 'left',
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
@@ -1453,28 +1195,25 @@ export function render({ navigate, route, chrome }) {
],
});
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
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.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
});
const topBarEl = createTopBar({
center: topTitle,
actions: [
{
iconNode: createOverflowDots(),
title: 'Ещё действия',
ariaLabel: 'Ещё действия',
className: 'channels-top-more-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
],
},
},
],
});
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@@ -1483,9 +1222,7 @@ export function render({ navigate, route, chrome }) {
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
renderListContent({
screen,
container: contentEl,
@@ -1496,7 +1233,6 @@ export function render({ navigate, route, chrome }) {
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
@@ -1516,10 +1252,7 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
channelsFilterMenu.destroy();
};
return screen;
+100 -208
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { directMessages } from '../mock-data.js';
@@ -32,7 +33,18 @@ import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } f
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
export const pageMeta = {
id: 'chat-view',
title: 'Чат',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'composer',
contentUnderTopbar: true,
contentUnderBottom: true,
scrollContainer: 'nested',
},
};
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function menuIconSvg(name) {
@@ -49,58 +61,6 @@ function menuIconSvg(name) {
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
}
function openUserIdentityMenu({ anchorEl, login, navigate }) {
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const cleanLogin = String(login || '').trim();
root.innerHTML = `
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
<span>Показать связи</span>
</button>
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Показать профиль</span>
</button>
</div>
</div>
`;
const layer = root.querySelector('#chat-user-menu-layer');
const menu = root.querySelector('.dm-user-identity-menu');
const close = () => {
document.removeEventListener('keydown', onKeydown);
root.innerHTML = '';
};
const onKeydown = (event) => {
if (event.key === 'Escape') close();
};
const rect = anchorEl.getBoundingClientRect();
window.requestAnimationFrame(() => {
if (!menu) return;
const width = menu.offsetWidth || 190;
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
});
layer?.addEventListener('pointerdown', (event) => {
if (event.target === layer) close();
});
document.addEventListener('keydown', onKeydown);
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
close();
navigate(makeProfileLinksRoute(cleanLogin));
});
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
close();
navigate(makeProfileRoute(cleanLogin));
});
}
function normalizeChatRelationType(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
@@ -178,15 +138,30 @@ function createChatHeaderParts(login, navigate) {
};
renderPeer();
identityButton.addEventListener('click', (event) => {
if (!cleanLogin || cleanLogin === 'unknown') return;
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
const identityMenu = createDropdownMenu({
transparent: true,
anchorEl: identityButton,
placement: 'bottom-start',
minWidth: 210,
items: [
{
label: 'Показать связи',
iconSrc: '/assets/SHiNE_connections_blue.svg',
action: () => navigate(makeProfileLinksRoute(cleanLogin)),
},
{
label: 'Показать профиль',
iconSrc: '/assets/profile-icon-profile.svg',
action: () => navigate(makeProfileRoute(cleanLogin)),
},
],
});
return {
centerNode: identityButton,
center: identityButton,
updatePeer,
getPeer: () => ({ ...currentPeer }),
cleanup: () => identityMenu.destroy(),
};
}
@@ -414,91 +389,6 @@ function openMessageActionsMenu({
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
showAddContact = false,
onCall,
onVideoCall,
onAddContact,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true"></span><span>Добавить в контакты</span></button>` : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
</div>
</div>
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
close();
if (typeof onAddContact === 'function') await onAddContact();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
});
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
close();
if (typeof onDeleteChat === 'function') await onDeleteChat();
});
}
function showTtsMissingConfigDialog() {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -853,7 +743,7 @@ function renderLog(
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
replyBox.className = 'ui-button bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
@@ -998,7 +888,6 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
}
export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -1140,10 +1029,10 @@ export function render({ navigate, route, chrome }) {
log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
const chatHeader = createTopBar({
center: chatHeaderParts.center,
back: { label: '←', onClick: () => navigate('messages-list') },
actions: [
{
title: 'Позвонить',
ariaLabel: 'Позвонить',
@@ -1156,22 +1045,28 @@ export function render({ navigate, route, chrome }) {
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onAddContact: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
menu: {
minWidth: 230,
items: () => [
{ label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') },
{ label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') },
normalizeChatRelationType(peerRelationType) === 'none'
? {
label: 'Добавить в контакты',
iconHtml: '<span aria-hidden="true"></span>',
action: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
}
},
onClearHistory: async () => {
openChatConfirmModal({
: null,
{
label: 'Очистить историю',
iconHtml: menuIconSvg('clear'),
action: () => openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
@@ -1184,55 +1079,54 @@ export function render({ navigate, route, chrome }) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
}),
},
onDeleteChat: async () => {
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
openDeleteChatConfirmModal({
contactName: contact.name,
relationType: relationBeforeDelete,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
{
label: 'Удалить чат',
iconHtml: menuIconSvg('delete'),
danger: true,
action: () => {
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
openDeleteChatConfirmModal({
contactName: contact.name,
relationType: relationBeforeDelete,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) await clearConversationHistory();
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
? ['close_friend', 'friend', 'contact']
: ['contact'];
for (const kind of relationKinds) {
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
const contactsPayload = await authService.listContacts();
setContacts(
contactsPayload?.contacts
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|| [],
);
notifyUnreadStateUpdated();
showToast('Чат удалён', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
// закономерно останется в списке из-за действующей связи.
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
? ['close_friend', 'friend', 'contact']
: ['contact'];
for (const kind of relationKinds) {
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
const contactsPayload = await authService.listContacts();
setContacts(
contactsPayload?.contacts
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|| [],
);
notifyUnreadStateUpdated();
showToast('Чат удалён', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
});
},
},
});
].filter(Boolean),
},
},
],
});
chatHeader.addCleanup(chatHeaderParts.cleanup);
chrome?.setTopbar(chatHeader);
@@ -1788,8 +1682,6 @@ export function render({ navigate, route, chrome }) {
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
};
return screen;
}
+11 -8
View File
@@ -1,19 +1,17 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить устройство',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
@@ -148,6 +146,11 @@ export function render({ navigate }) {
}
});
screen.append(card, helpModal);
screen.append(card);
const modalRoot = document.getElementById('modal-root');
modalRoot?.append(helpModal);
screen.cleanup = () => {
helpModal.remove();
};
return screen;
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -58,7 +58,7 @@ function createSearchAvatar(login) {
return avatarEl;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-search-screen';
let searchTimer = 0;
@@ -172,11 +172,11 @@ export function render({ navigate }) {
resultsCard.append(status, resultsList);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Поиск контактов',
leftAction: { label: '←', onClick: () => navigate('messages-list') },
}),
back: { label: '←', onClick: () => navigate('messages-list') },
}));
screen.append(
formCard,
resultsCard,
);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, state } from '../state.js';
import {
isClientErrorReportingEnabled,
@@ -246,16 +246,14 @@ function openUiErrorReportingModal() {
});
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки разработчика',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
+5 -7
View File
@@ -1,17 +1,15 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить через камеру',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const frame = document.createElement('div');
frame.className = 'camera-shell';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -165,7 +165,7 @@ function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
} catch {}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
let savedKeys = null;
@@ -178,12 +178,10 @@ export function render({ navigate }) {
let dialogMode = '';
let pendingTransferRequest = null;
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить по коду',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const transferDialog = document.createElement('div');
transferDialog.className = 'pairing-transfer-dialog';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import {
@@ -9,16 +9,14 @@ import {
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Показать QR-код',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack qr-card';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
isSessionInvalidError,
@@ -31,19 +31,17 @@ function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server';
}
export function render({ navigate, route }) {
export function render({navigate, route, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
const sessionId = route?.params?.sessionId || '';
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Сеанс устройства',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
if (!session) {
const empty = document.createElement('div');
+6 -8
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
refreshSessions,
@@ -40,16 +40,14 @@ function sortSessionsByOnline(sessions = []) {
});
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Устройства',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const actions = document.createElement('div');
actions.className = 'card stack';
@@ -75,7 +73,7 @@ export function render({ navigate }) {
const createSessionItem = (session, isCurrent) => {
const item = document.createElement('button');
item.className = 'session-item';
item.className = 'ui-button session-item';
item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType);
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
+2 -2
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false };
@@ -181,7 +181,7 @@ export function render() {
})();
screen.append(
renderHeader({
createTopBar({
title: 'DEVNET пополнение',
}),
senderBox,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authorizeLocalDemoSession,
isLocalDemoAvailable,
@@ -173,9 +173,9 @@ export function render({ navigate }) {
actions.append(serverUiButton, cancelButton, saveButton);
screen.append(
renderHeader({
createTopBar({
title: 'Настройки входа',
leftAction: { label: '←', onClick: () => navigate('start-view') },
back: { label: '←', onClick: () => navigate('start-view') },
}),
body,
actions,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authorizeSession, state } from '../state.js';
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
@@ -91,9 +91,9 @@ export function render({ navigate }) {
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
createTopBar({
title: 'Какие ключи сохранить',
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
back: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { saveEntryLanguage, state } from '../state.js';
export const pageMeta = { id: 'language-view', title: 'Язык' };
@@ -8,21 +8,19 @@ function resolveReturnPage() {
return stored === 'start-view' ? 'start-view' : 'settings-view';
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack language-screen';
const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Язык / Language',
leftAction: { label: '←', onClick: () => {
back: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
} },
}),
);
}));
const card = document.createElement('div');
card.className = 'card stack language-choice-card';
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -244,9 +244,9 @@ export function render({ navigate }) {
});
screen.append(
renderHeader({
createTopBar({
title: 'Войти по QR-коду',
leftAction: {
back: {
label: '←',
onClick: () => {
stopCamera();
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
clearAuthMessages,
@@ -170,9 +170,9 @@ export function render({ navigate }) {
panel.append(title, passwordField, status, actions);
screen.append(
renderHeader({
createTopBar({
title: '',
leftAction: { label: '←', onClick: () => navigate('login-view') },
back: { label: '←', onClick: () => navigate('login-view') },
}),
panel,
overlay,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
@@ -154,9 +154,9 @@ export function render({ navigate }) {
panel.append(title, loginField, status, remoteWrap, actions);
screen.append(
renderHeader({
createTopBar({
title: '',
leftAction: {
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
+37 -117
View File
@@ -10,6 +10,7 @@ import {
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -200,34 +201,19 @@ export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand">
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
</div>
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
<div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
</div>
</div>
`;
head.querySelector('.dm-head-logo-wrap')?.append(
createShineConnectionsLogo({ className: 'dm-head-logo' }),
);
const menuButton = head.querySelector('.dm-head-menu-btn');
menuButton?.append(createOverflowDots());
const brand = document.createElement('div');
brand.className = 'dm-head-brand';
const logoWrap = document.createElement('span');
logoWrap.className = 'dm-head-logo-wrap';
logoWrap.setAttribute('aria-hidden', 'true');
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
brand.append(logoWrap);
let currentChatFilter = 'all';
const filterTitle = head.querySelector('#dm-chat-filter-title');
const filterTitle = document.createElement('button');
filterTitle.type = 'button';
filterTitle.className = 'dm-head-filter-title';
filterTitle.textContent = 'Чаты';
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
@@ -237,8 +223,9 @@ export function render({ navigate, chrome }) {
};
let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: filterTitle,
align: 'left',
placement: 'bottom-start',
leftShift: 72,
minWidth: 225,
items: [
@@ -250,92 +237,31 @@ export function render({ navigate, chrome }) {
],
});
const menuWrap = head.querySelector('.dm-head-menu-wrap');
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
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
const titleRect = filterTitle?.getBoundingClientRect?.();
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const head = createTopBar({
left: brand,
center: filterTitle,
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню чатов',
ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn',
menu: {
minWidth: 210,
items: [
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
],
},
},
],
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onPeerDropdownOpen = (event) => {
if (!menuPortal || event?.detail?.owner === menuButton) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const list = document.createElement('div');
list.className = 'stack dm-list';
@@ -522,13 +448,7 @@ function renderRow(item) {
loadList();
screen.cleanup = () => {
closeHeadMenu();
chatFilterMenu.destroy();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen;
+28 -25
View File
@@ -1,5 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
@@ -9,7 +8,18 @@ import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu } from './network/node-menu.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
export const pageMeta = {
id: 'network-view',
title: 'Связи',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'toolbar',
fadeProfile: 'edge',
contentUnderTopbar: true,
scrollContainer: 'locked',
},
};
const GENDER_MALE = 'male';
const GENDER_FEMALE = 'female';
@@ -202,8 +212,6 @@ export function render({ navigate, route, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'network-screen';
const appScreenEl = document.getElementById('app-screen');
appScreenEl?.classList.add('network-scroll-lock');
const stage = document.createElement('div');
stage.className = 'network-stage';
@@ -434,39 +442,34 @@ export function render({ navigate, route, chrome } = {}) {
}
}
const header = renderHeader({
title: 'Связи',
rightActions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
onClick: () => {},
},
],
});
const networkMenuButton = header.querySelector('.network-header-menu-btn');
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const networkMenu = createDropdownMenu({
anchorEl: networkMenuButton,
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
const header = createTopBar({
title: 'Связи',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
},
],
});
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
networkMenu.destroy();
if (engine) engine.destroy();
engine = null;
appScreenEl?.classList.remove('network-scroll-lock');
};
if (routeLogin) {
+4 -4
View File
@@ -534,7 +534,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
if (dotOnly) {
el.className = [
'fg-node', 'fg-dot',
'ui-button', 'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -546,7 +546,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return el;
}
el.className = [
'fg-node',
'ui-button', 'fg-node',
isFocus ? 'is-focus' : '',
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -628,8 +628,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
node.el.className = spec.dotOnly
? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
}
// --- Рендер ----------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ export function openNodeMenu({ login, displayName = '', relationType, point, act
if (!(root instanceof HTMLElement)) return;
const itemsHtml = actions
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
.map((a, i) => `<button class="ui-button fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
.join('');
root.innerHTML = `
+2 -2
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -320,7 +320,7 @@ function renderItem(item, activeTab, navigate) {
export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'stack notifications-screen';
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
+4 -4
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
@@ -107,9 +107,9 @@ export function render({ navigate, chrome }) {
screen.className = 'stack profile-screen';
chrome?.setTopbar(
renderHeader({
createTopBar({
title: 'Редактирование профиля',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
back: { label: '←', onClick: () => navigate('profile-view') },
}),
);
@@ -454,7 +454,7 @@ export function render({ navigate, chrome }) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = values.map((value) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
)).join('');
};
+26 -27
View File
@@ -2,7 +2,7 @@ import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -57,28 +57,28 @@ export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile user-profile-header';
topbar.innerHTML = `
<div class="header-left" aria-hidden="true"></div>
<div class="header-center"><h1 class="page-title">${escapeHtml(login || 'Профиль')}</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"></button>
</div>`;
const menuButton = topbar.querySelector('.profile-head-menu-btn');
menuButton?.append(createOverflowDots());
const profileMenu = createDropdownMenu({
anchorEl: menuButton,
className: 'profile-head-menu',
minWidth: 250,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
const topbar = createTopBar({
title: login || 'Профиль',
className: 'topbar--profile user-profile-header',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn',
menu: {
className: 'profile-head-menu',
minWidth: 250,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
},
},
],
});
chrome?.setTopbar(topbar);
@@ -104,7 +104,7 @@ export function render({ navigate, chrome }) {
.join(' ');
const about = String(card.about || '').trim();
const title = topbar.querySelector('.page-title');
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = `
@@ -140,10 +140,10 @@ export function render({ navigate, chrome }) {
</div>
<div class="user-profile-detail-links" aria-label="Дополнительная информация о профиле">
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
@@ -220,6 +220,5 @@ export function render({ navigate, chrome }) {
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
});
screen.cleanup = () => profileMenu.destroy();
return screen;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
closeAllSavedProfiles,
closeSavedProfile,
@@ -15,13 +15,13 @@ function reloadTo(path) {
window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack profiles-screen';
screen.append(renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Профили',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const intro = document.createElement('div');
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { defaultSolanaCluster } from '../deploy-config.js';
import { state } from '../state.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
@@ -262,7 +262,7 @@ function readTicketFromUrl() {
}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -404,11 +404,11 @@ export function render({ navigate }) {
});
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Очередь билета',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
back: { label: '←', onClick: () => navigate('start-view') },
}));
screen.append(
content,
);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
@@ -265,16 +265,14 @@ function buildRecommendations(diag) {
return rows;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Диагностика PWA / Push',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
const statusCard = document.createElement('div');
statusCard.className = 'card stack';
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -424,9 +424,9 @@ export function render({ navigate }) {
renderInputStage();
screen.append(
renderHeader({
createTopBar({
title: 'Зарегистрироваться',
leftAction: {
back: {
label: '←',
onClick: () => {
resetRegistrationFlow();
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
@@ -178,9 +178,9 @@ export function render({ navigate }) {
actions.append(backButton);
screen.append(
renderHeader({
createTopBar({
title: 'Сгенерированные ключи',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
back: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
actions,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
@@ -215,9 +215,9 @@ export function render({ navigate }) {
actions.append(backButton, registerButton);
screen.append(
renderHeader({
createTopBar({
title: 'Вопросы о регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
back: { label: '←', onClick: () => navigate('register-view') },
}),
heroCard,
topicCard,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
@@ -193,9 +193,9 @@ export function render({ navigate }) {
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
createTopBar({
title: 'Сохранение ключей',
leftAction: {
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -374,9 +374,9 @@ export function render({ navigate }) {
card.append(showKeysButton, submitButton, status);
screen.append(
renderHeader({
createTopBar({
title: 'Оплата регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
back: { label: '←', onClick: () => navigate('register-view') },
}),
card,
);
@@ -398,7 +398,7 @@ export function render({ navigate }) {
function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
const headerBackButton = screen.querySelector('.topbar .topbar__back');
const card = screen.querySelector('.card.stack');
if (!card) return;
card.classList.add('registration-finish-card');
@@ -585,7 +585,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
const headerBackButton = screen.querySelector('.topbar .topbar__back');
const card = screen.querySelector('.card.stack');
if (!card) return;
card.classList.add('registration-finish-card');
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
refreshSessions,
@@ -29,16 +29,14 @@ function sessionLabel(session) {
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'AddBlock через homeserver',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const actions = document.createElement('div');
actions.className = 'card stack';
@@ -69,7 +67,7 @@ export function render({ navigate }) {
sessions.forEach((session) => {
const sessionId = String(session?.sessionId || '').trim();
const item = document.createElement('button');
item.className = 'session-item';
item.className = 'ui-button session-item';
item.type = 'button';
const isSelected = sessionId && sessionId === selectedId;
item.innerHTML = `
+7 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { saveEntrySettings, state } from '../state.js';
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
@@ -10,7 +10,7 @@ const SERVER_FIELDS = [
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -185,7 +185,7 @@ export function render({ navigate }) {
actions.append(cancelButton, saveButton);
const help = document.createElement('button');
help.className = 'help-fab';
help.className = 'ui-button help-fab';
help.type = 'button';
help.textContent = '?';
help.addEventListener('click', () => {
@@ -194,11 +194,11 @@ export function render({ navigate }) {
);
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Серверы блокчейнов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
introCard,
body,
actions,
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -25,17 +25,15 @@ function formatVersionForUi(rawValue) {
return value;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
let isDisposed = false;
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
@@ -6,7 +6,7 @@ import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -22,12 +22,10 @@ export function render({ navigate }) {
device: '',
};
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Показать ключи',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
@@ -132,7 +132,7 @@ function makeResultCard(endpoint) {
return { card, badge, statusLine, details };
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -242,11 +242,11 @@ export function render({ navigate }) {
});
resetBtn.addEventListener('click', resetState);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Проверка Solana RPC',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
screen.append(
intro,
summary,
grid,
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
@@ -29,7 +29,7 @@ function shortAddr(value = '') {
return `${v.slice(0, 6)}...${v.slice(-6)}`;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -184,11 +184,11 @@ export function render({ navigate }) {
status,
);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Solana Init (users)',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
screen.append(
card,
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import {
createSolanaWalletFromPrivateBase58,
@@ -161,9 +161,9 @@ export function render({ navigate }) {
})();
screen.append(
renderHeader({
createTopBar({
title: 'Пополнение solana счета',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
back: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
status,
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -18,7 +18,7 @@ function describeState(settings) {
return 'Вход через другое устройство разрешён без дополнительного пароля.';
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -182,11 +182,11 @@ export function render({ navigate }) {
}
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки входа через устройство',
leftAction: { label: '←', onClick: () => navigate('device-pairing-view') },
}),
back: { label: '←', onClick: () => navigate('device-pairing-view') },
}));
screen.append(
card,
);
+9 -5
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService } from '../state.js';
import { navigateBack } from '../router.js';
@@ -18,13 +18,17 @@ const TITLES = {
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
};
export function render({ navigate, route }) {
export function render({navigate, route, chrome}) {
const login = String(route?.params?.login || '').trim();
const kind = String(route?.params?.kind || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack';
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } }));
screen.append(
status,
body,
);
(async () => {
try {
@@ -32,7 +36,7 @@ export function render({ navigate, route }) {
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
const t = document.createElement('div'); t.className = 'profile-list-row-text';
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
@@ -46,7 +50,7 @@ export function render({ navigate, route }) {
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
const rows = Array.isArray(payload?.users) ? payload.users : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
const fullName = userDisplayName(row);
const t = document.createElement('div'); t.className = 'profile-list-row-text';
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
@@ -87,9 +87,9 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const header = renderHeader({
const header = createTopBar({
title: requestedLogin || 'Профиль',
leftAction: { label: '←', onClick: () => navigateBack() },
back: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
chrome?.setTopbar(header);
@@ -176,7 +176,7 @@ export function render({ navigate, route, chrome }) {
.join(' ');
const about = String(card.about || '').trim();
const title = header.querySelector('.page-title');
const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login;
body.innerHTML = `
@@ -214,10 +214,10 @@ export function render({ navigate, route, chrome }) {
</div>` : ''}
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { navigateBack } from '../router.js';
@@ -7,11 +7,15 @@ export const pageMeta = { id: 'user-relation-manage-view', title: 'Добави
function effectiveSocial(f) { if (f.outCloseFriend) return 'close_friend'; if (f.outFriend) return 'friend'; if (f.outContact) return 'contact'; return 'none'; }
export function render({ route }) {
export function render({route, chrome}) {
const targetLogin = String(route?.params?.login || '').trim(); const selfLogin = String(state.session.login || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack'; const status = document.createElement('div'); status.className = 'status-line';
screen.append(renderHeader({ title: 'Добавить', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
chrome?.setTopbar(createTopBar({ title: 'Добавить', back: { label: '←', onClick: () => navigateBack() } }));
screen.append(
status,
body,
);
let flags, selfCard, targetCard;
async function setKind(kind, enabled) { await authService.setUserRelation({ login: selfLogin, toLogin: targetLogin, kind, enabled, storagePwd: state.session.storagePwdInMemory }); }
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { defaultSolanaCluster } from '../deploy-config.js';
import { authService, state } from '../state.js';
import {
@@ -446,7 +446,7 @@ function ticketPdaFor(programId, queueId, index) {
);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -459,11 +459,11 @@ export function render({ navigate }) {
const content = document.createElement('div');
content.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
back: { label: '←', onClick: () => navigate('profile-view') },
}));
screen.append(
content,
status,
);