import { renderHeader } from '../components/header.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'; import { animatePress, createSkeletonCard, longPressFeel, shareOrCopyLink, showToast, softHaptic, } from '../services/channels-ux.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { navigateBack } from '../router.js'; export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; const pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); function logThreadRuntimeError(stage, error, context = {}) { const message = String(error?.message || error || 'thread runtime error'); console.error(`[channel-thread-view:${stage}]`, error, context); captureClientError({ kind: 'channels_thread_runtime', message, stack: error?.stack || '', context: { stage, ...context }, }); } function encodeRoutePart(value = '') { return encodeURIComponent(String(value)); } function normalizeRouteHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); return normalized || '0'; } function normalizeMessageHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(normalized)) return ''; if (/^0+$/.test(normalized)) return ''; return normalized; } function toSafeInt(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function looksLikeBlockchainName(value) { const raw = String(value || '').trim(); return /^[^-]+-\d+$/.test(raw); } function extractLoginFromBlockchainName(value) { const raw = String(value || '').trim(); const match = raw.match(/^(.+)-\d+$/); if (!match) return ''; return String(match[1] || '').trim(); } function makeReactionActionKey(messageRef) { const login = String(state.session.login || '').trim().toLowerCase(); const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockNumber = Number(messageRef?.blockNumber); const blockHash = normalizeMessageHash(messageRef?.blockHash); if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return ''; return `${login}|${blockchainName}|${blockNumber}|${blockHash}`; } function messageRefKey(messageRef) { const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockNumber = Number(messageRef?.blockNumber); const blockHash = normalizeMessageHash(messageRef?.blockHash); if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return ''; return `${blockchainName}:${blockNumber}:${blockHash}`; } function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); url.pathname = `/${cleanRoute}`; url.hash = ''; return url.toString(); } function parseThreadSelector(route) { const params = route?.params || {}; if (params.ownerBlockchainName && params.channelName && params.messageBlockNumber) { return { short: { ownerBlockchainName: String(params.ownerBlockchainName || '').trim(), channelName: String(params.channelName || '').trim(), }, message: { blockchainName: '', blockNumber: toSafeInt(params.messageBlockNumber), blockHash: normalizeRouteHash(params.messageBlockHash), }, channel: { ownerBlockchainName: '', channelRootBlockNumber: null, channelRootBlockHash: '0', }, }; } const blockNumber = toSafeInt(params.messageBlockNumber); if (!params.messageBlockchainName || blockNumber == null) return null; return { message: { blockchainName: String(params.messageBlockchainName), blockNumber, blockHash: normalizeRouteHash(params.messageBlockHash), }, channel: { ownerBlockchainName: String(params.channelOwnerBlockchainName || ''), channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber), channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash), }, }; } function allFeedSummaries() { const feed = state.channelsFeed || {}; return [ ...(feed.ownedChannels || []), ...(feed.followedUsersChannels || []), ...(feed.followedChannels || []), ]; } function resolveChannelDisplayName(channelSelector) { const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber; const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash; if (!channelSelector?.ownerBlockchainName || rootNumber == null) return ''; const ownerBch = String(channelSelector.ownerBlockchainName); const rootNo = Number(rootNumber); const rootHash = normalizeRouteHash(rootHashRaw); const found = allFeedSummaries().find((summary) => ( String(summary?.channel?.ownerBlockchainName || '') === ownerBch && Number(summary?.channel?.channelRoot?.blockNumber) === rootNo && normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash )); if (!found) return ''; return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; } function extractChannelContextFromThreadPayload(payload) { const focusInfo = payload?.focus?.channelInfo; if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { return { ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber), channelRootBlockHash: '0', }; } const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; for (let i = ancestors.length - 1; i >= 0; i -= 1) { const info = ancestors[i]?.channelInfo; if (info?.ownerBlockchainName && info?.channelRoot?.blockNumber != null) { return { ownerBlockchainName: String(info.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(info.channelRoot.blockNumber), channelRootBlockHash: '0', }; } } return null; } async function resolveChannelDisplayNameFromServer(channelSelector) { const ownerBch = String(channelSelector?.ownerBlockchainName || '').trim(); const rootNo = Number(channelSelector?.channelRootBlockNumber); if (!ownerBch || !Number.isFinite(rootNo) || rootNo < 0) return ''; const ownerLogin = extractLoginFromBlockchainName(ownerBch); if (!ownerLogin) return ''; try { const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000); const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const row = rows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch.toLowerCase() && Number(item?.channel?.channelRoot?.blockNumber) === rootNo )); if (!row?.channel?.channelName) return ''; channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash); return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`; } catch { return ''; } } function buildThreadRouteFromTarget(target, selector) { if (!target) return ''; return [ 'm', encodeRoutePart(target.blockchainName), target.blockNumber, ].join('/'); } function buildTargetFromNode(node) { const blockchainName = String(node?.authorBlockchainName || '').trim(); const blockNumber = Number(node?.messageRef?.blockNumber); const blockHash = normalizeMessageHash(node?.messageRef?.blockHash); if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; return { blockchainName, blockNumber, blockHash }; } function firstNonEmptyText(...candidates) { for (const candidate of candidates) { if (typeof candidate !== 'string') continue; const trimmed = candidate.trim(); if (trimmed.length > 0) return candidate; } return ''; } function latestVersionText(versions) { if (!Array.isArray(versions)) return ''; for (let i = versions.length - 1; i >= 0; i -= 1) { const version = versions[i]; const value = firstNonEmptyText(version?.text, version?.message, version?.body); if (value) return value; } return ''; } function resolveNodeText(node) { return firstNonEmptyText( node?.text, node?.message, node?.body, latestVersionText(node?.versions), ); } function openReplyModal({ onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#thread-reply-text'); const errorEl = root.querySelector('#thread-reply-error'); const submitEl = root.querySelector('#thread-reply-submit'); let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; submitEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить'; }; const close = () => { root.innerHTML = ''; }; root.querySelector('#thread-reply-cancel')?.addEventListener('click', close); root.querySelector('#thread-reply-voice')?.addEventListener('click', async () => { await openSpeechInputModal({ navigate, onTextReady: (text) => { const prev = String(textEl?.value || '').trim(); if (textEl) textEl.value = prev ? `${prev} ${text}` : text; }, }); }); root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => { if (inFlight) return; const text = String(textEl?.value || '').trim(); if (!text) { errorEl.textContent = 'Введите текст ответа.'; return; } setBusy(true); errorEl.textContent = ''; try { await onSubmit(text); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.'); } }); if (textEl) textEl.focus(); } function renderNodeCard(node, heading, handlers, localNumber) { const card = document.createElement('article'); card.className = 'card stack thread-node-card channel-message-card'; const author = node?.authorLogin || 'автор'; const text = resolveNodeText(node) || '(пусто)'; const likes = Number(node?.likesCount || 0); const replies = Number(node?.repliesCount || 0); const headingText = String(heading || '').trim(); if (headingText) { const headingEl = document.createElement('strong'); headingEl.className = 'thread-node-heading'; headingEl.textContent = headingText; card.append(headingEl); } const authorTile = document.createElement('button'); authorTile.type = 'button'; authorTile.className = 'channel-message-author-tile'; const avatar = document.createElement('div'); avatar.className = 'channel-message-avatar'; avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A'; const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; const title = document.createElement('div'); title.className = 'channel-message-title author-line'; const loginEl = document.createElement('span'); loginEl.className = 'author-line-login'; loginEl.textContent = author; const numberEl = document.createElement('span'); numberEl.className = 'author-line-num'; numberEl.textContent = `· #${localNumber}`; const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; title.append(loginEl, numberEl); authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); const body = document.createElement('p'); body.className = 'channel-message-body'; body.textContent = text; card.append(authorTile, body); const target = buildTargetFromNode(node); const refKey = messageRefKey(target); if (!target || !handlers) return card; if (refKey) card.dataset.messageKey = refKey; setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked'); const actionKey = makeReactionActionKey(target); const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; const isLiked = getMessageReactionState(target) === 'liked'; const actions = document.createElement('div'); actions.className = 'thread-node-actions channel-message-actions'; const likeButton = document.createElement('button'); likeButton.type = 'button'; likeButton.className = 'channel-action-item thread-like-btn'; if (isLiked) likeButton.classList.add('is-liked'); likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} ${likes} `; likeButton.disabled = isPending; likeButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); if (isPending) return; if (!isLiked) { const ok = window.confirm('Поставить лайк?'); if (!ok) return; } await longPressFeel(event.currentTarget, 130); likeButton.disabled = true; try { await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); } catch (error) { logThreadRuntimeError('like_click', error, { action: isLiked ? 'unlike' : 'like', targetBlockchainName: target?.blockchainName || '', targetBlockNumber: target?.blockNumber, }); handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like'); } }); const replyButton = document.createElement('button'); replyButton.type = 'button'; replyButton.className = 'channel-action-item thread-reply-btn'; replyButton.innerHTML = ` Ответить ${replies} `; replyButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openReplyModal({ navigate: handlers.navigate, onSubmit: async (textValue) => handlers.onReply(target, textValue), }); }); const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'channel-action-item thread-share-btn'; shareButton.innerHTML = ` Отправить `; shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); await handlers.onShare(target); }); const openThreadButton = document.createElement('button'); openThreadButton.type = 'button'; openThreadButton.className = 'channel-action-item thread-open-btn'; openThreadButton.innerHTML = ` Тред `; openThreadButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); handlers.onOpenThread(target); }); actions.append(likeButton, replyButton, openThreadButton, shareButton); card.append(actions); authorTile.addEventListener('click', (event) => { event.stopPropagation(); const login = String(node?.authorLogin || '').trim(); if (!login) return; handlers.navigate(`user/${encodeRoutePart(login)}`); }); card.addEventListener('click', () => { handlers.onOpenThread(target); }); return card; } function renderDescendants(items, handlers, nextNumber, depth = 0) { const wrap = document.createElement('div'); wrap.className = 'stack'; const normalized = Array.isArray(items) ? items : []; normalized.forEach((branch, index) => { try { const nodeNumber = nextNumber(); const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber); row.classList.add('thread-node-level'); row.style.setProperty('--depth', String(Math.min(depth, 4))); wrap.append(row); if (Array.isArray(branch?.children) && branch.children.length) { wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1)); } } catch (error) { logThreadRuntimeError('render_descendants_branch', error, { depth, index }); } }); return wrap; } function applyPendingScroll(screen, routeKey) { const target = pendingThreadScroll.get(routeKey); if (!target) return; const doScroll = () => { if (target === '__LAST_REPLY__') { const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]'); const last = cards[cards.length - 1]; if (last) { last.scrollIntoView({ behavior: 'smooth', block: 'center' }); } pendingThreadScroll.delete(routeKey); return; } const node = screen.querySelector(`[data-message-key="${target}"]`); if (node) { node.scrollIntoView({ behavior: 'smooth', block: 'center' }); pendingThreadScroll.delete(routeKey); } }; setTimeout(doScroll, 20); } function renderSkeleton(screen) { const wrap = document.createElement('div'); wrap.className = 'stack'; wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard()); screen.append(wrap); return wrap; } export function render({ navigate, route }) { const selector = parseThreadSelector(route); const channelDisplayName = resolveChannelDisplayName(selector?.channel); const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`; 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'); const channelIndicator = document.createElement('div'); channelIndicator.className = 'card channels-user-chip'; channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`; const statusBox = document.createElement('div'); 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.replaceWith(next); } catch (error) { logThreadRuntimeError('rerender', error, { routePath: window.location.pathname }); } }; const showStatus = (message) => { if (!message) { statusBox.style.display = 'none'; statusBox.textContent = ''; return; } statusBox.textContent = message; statusBox.style.display = ''; }; const requireSigningSession = () => { const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { state.authReturnHash = window.location.pathname || '/channels-list'; navigate('login-view'); throw new Error('Для этого действия нужно войти'); } return { login, storagePwd }; }; const handlers = { navigate, onToggleLike: async (target, action) => { const actionKey = makeReactionActionKey(target); if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.'); if (pendingReactionActions.has(actionKey)) return; const previousReaction = getMessageReactionState(target); const nextReaction = action === 'unlike' ? 'unliked' : 'liked'; pendingReactionActions.add(actionKey); try { const { login, storagePwd } = requireSigningSession(); if (action === 'unlike') { await authService.addBlockUnlike({ login, storagePwd, message: target }); } else { await authService.addBlockLike({ login, storagePwd, message: target }); } setMessageReactionState(target, nextReaction); softHaptic(10); rerender(); } catch (error) { setMessageReactionState(target, previousReaction || 'unliked'); rerender(); throw error; } finally { pendingReactionActions.delete(actionKey); } }, onReply: async (target, textValue) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockReply({ login, storagePwd, message: target, text: textValue }); pendingThreadScroll.set(routeKey, '__LAST_REPLY__'); softHaptic(15); showToast('Ответ отправлен'); showStatus(''); rerender(); }, onShare: async (target) => { try { const routePath = buildThreadRouteFromTarget(target, selector); if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.'); const result = await shareOrCopyLink({ title: 'SHiNE · Тред', text: 'Сообщение из треда SHiNE', url: buildAbsoluteRouteUrl(routePath), }); if (result === 'copied') showToast('Ссылка скопирована'); if (result === 'shared') showToast('Ссылка передана'); if (result === 'copied' || result === 'shared') softHaptic(10); } catch (error) { showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.')); } }, onOpenThread: (target) => { const routePath = buildThreadRouteFromTarget(target, selector); if (!routePath) { showStatus('Не удалось определить путь до треда.'); return; } navigate(routePath); }, onActionError: (error, action) => { const fallback = action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'; showStatus(toUserMessage(error, fallback)); }, }; screen.append( renderHeader({ title: 'Тред', leftAction: { label: '<', onClick: () => navigateBack() }, }), ); screen.append(channelIndicator, statusBox); if (!selector) { const invalid = document.createElement('div'); invalid.className = 'card meta-muted'; invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.'; screen.append(invalid); return screen; } 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); const allRows = [ ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), ]; const ownerRaw = String(selector.short.ownerBlockchainName || '').trim(); const ownerNormalized = ownerRaw.toLowerCase(); const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw); const channelNameNormalized = String(selector.short.channelName || '').trim().toLowerCase(); let channel = allRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); if (!channel) { channel = allRows.find((item) => ( String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } if (!channel && !looksLikeBlockchainName(ownerRaw)) { try { const ownerUser = await authService.getUser(ownerRaw); const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); if (ownerBch) { channel = allRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } } catch { // ignore fallback lookup errors } } if (!channel && ownerLoginFromBch) { try { const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500); const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; channel = ownerRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } catch { // ignore owner feed lookup errors } } const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim(); const rootNo = Number(channel?.channel?.channelRoot?.blockNumber); const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash); if (!ownerBch || !Number.isFinite(rootNo) || !Number.isFinite(resolvedMessage?.blockNumber)) { throw new Error('Канал или сообщение не найдено.'); } selector.channel = { ownerBlockchainName: ownerBch, channelRootBlockNumber: rootNo, channelRootBlockHash: rootHash, }; resolvedMessage = { blockchainName: ownerBch, blockNumber: resolvedMessage.blockNumber, blockHash: normalizeMessageHash(resolvedMessage?.blockHash), }; } const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login); skeleton.remove(); const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; const focus = payload?.focus || null; const descendants = Array.isArray(payload?.descendants) ? payload.descendants : []; const focusHash = normalizeMessageHash(focus?.messageRef?.blockHash); if (focusHash && selector?.message) { selector.message.blockHash = focusHash; } if ((!selector?.channel?.ownerBlockchainName || selector?.channel?.channelRootBlockNumber == null) && payload) { const context = extractChannelContextFromThreadPayload(payload); if (context) { selector.channel = { ownerBlockchainName: context.ownerBlockchainName, channelRootBlockNumber: context.channelRootBlockNumber, channelRootBlockHash: normalizeRouteHash(context.channelRootBlockHash), }; } } let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel); if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) { resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel); } const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно'; channelIndicator.textContent = `Канал: ${resolvedChannelLabel || fallbackChannel}`; let seq = 0; const nextNumber = () => { seq += 1; return seq; }; if (ancestors.length) { const ancestorsWrap = document.createElement('div'); ancestorsWrap.className = 'stack thread-block thread-block--ancestors'; const title = document.createElement('h3'); title.className = 'section-title'; title.textContent = 'История выше (на что это ответ)'; ancestorsWrap.append(title); ancestors.forEach((node, index) => { ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber())); }); screen.append(ancestorsWrap); } if (focus) { const focusWrap = document.createElement('div'); focusWrap.className = 'stack thread-block thread-block--focus'; const focusTitle = document.createElement('h3'); focusTitle.className = 'section-title'; focusTitle.textContent = 'Текущее сообщение'; focusWrap.append(focusTitle); focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber())); screen.append(focusWrap); } const descendantsWrap = document.createElement('div'); descendantsWrap.className = 'stack thread-block thread-block--replies'; const descendantsTitle = document.createElement('h3'); descendantsTitle.className = 'section-title'; descendantsTitle.textContent = 'Ответы'; descendantsWrap.append(descendantsTitle); if (descendants.length) { descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber)); } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Ответов пока нет.'; descendantsWrap.append(empty); } screen.append(descendantsWrap); applyPendingScroll(screen, routeKey); } catch (error) { 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'); }; return screen; }