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