Обновить 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
+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;
}