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'; import { renderUserAvatar } from '../components/avatar-image.js'; 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: 'Тред' }; const pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); const threadAvatarSnapshotCache = new Map(); const threadAvatarPendingByLogin = new Map(); async function loadThreadAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; const key = cleanLogin.toLowerCase(); if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key); if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key); const pending = loadProfileSnapshot(cleanLogin) .then((snapshot) => { threadAvatarSnapshotCache.set(key, snapshot || null); threadAvatarPendingByLogin.delete(key); return snapshot || null; }) .catch(() => { threadAvatarSnapshotCache.set(key, null); threadAvatarPendingByLogin.delete(key); return null; }); threadAvatarPendingByLogin.set(key, pending); return pending; } function createThreadAvatar(login) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', size: 'small', className: 'channel-message-avatar', title, }); if (!cleanLogin) return avatarEl; void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, size: 'small', className: 'channel-message-avatar', title, }); avatarEl.replaceWith(upgraded); }); return avatarEl; } 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 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 ''; const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); return makeShineMessageRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName), messageBlockchainName: target.blockchainName, messageBlockNumber: target.blockNumber, }); } function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') { const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { return makeShineChannelRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch), ownerBlockchainName: ownerBch, channelName: selector.short.channelName, }); } const label = String(resolvedChannelLabel || '').trim(); const slashIndex = label.indexOf('/'); const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : ''; return makeShineChannelRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch), ownerBlockchainName: ownerBch, channelName, }); } 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 buildRepostTargetFromNode(node) { const blockchainName = String(node?.targetBlockchainName || '').trim(); const blockNumber = Number(node?.targetBlockNumber); const blockHash = normalizeMessageHash(node?.targetBlockHash); 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) || !versions.length) return ''; const version = versions[versions.length - 1]; if (typeof version?.text === 'string') return version.text; if (typeof version?.message === 'string') return version.message; if (typeof version?.body === 'string') return version.body; 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 openRepostModal({ navigate, channels = [], onSubmit }) { const root = document.getElementById('modal-root'); const options = (Array.isArray(channels) ? channels : []) .filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber))) .map((item, index) => { const owner = String(item?.ownerLogin || '').trim(); const name = String(item?.channelName || '').trim(); const label = `${owner || 'my'} / ${name || 'stories'}`; return ``; }) .join(''); root.innerHTML = ` `; const selectEl = root.querySelector('#thread-repost-channel-select'); const textEl = root.querySelector('#thread-repost-comment'); const errorEl = root.querySelector('#thread-repost-error'); const submitEl = root.querySelector('#thread-repost-submit'); let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; if (selectEl) selectEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; if (submitEl) { submitEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост'; } }; const close = () => { root.innerHTML = ''; }; root.querySelector('#thread-repost-cancel')?.addEventListener('click', close); root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => { await openSpeechInputModal({ navigate, onTextReady: (text) => { const prev = String(textEl?.value || '').trim(); if (textEl) textEl.value = prev ? `${prev} ${text}` : text; }, }); }); submitEl?.addEventListener('click', async () => { if (inFlight) return; const idx = Number(selectEl?.value ?? -1); if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) { errorEl.textContent = 'Выберите канал для репоста.'; return; } const text = String(textEl?.value || '').trim(); if (!text) { errorEl.textContent = 'Введите комментарий к репосту.'; return; } setBusy(true); errorEl.textContent = ''; try { await onSubmit({ channel: channels[idx].selector, text }); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.'); } }); if (textEl) textEl.focus(); } function openMessageHistoryModal({ versions = [], title = 'История изменений' }) { const root = document.getElementById('modal-root'); const rows = Array.isArray(versions) ? versions : []; root.innerHTML = ` `; const list = root.querySelector('#thread-history-list'); if (list) { rows.forEach((item, index) => { const row = document.createElement('div'); row.className = 'card stack'; const ts = Number(item?.createdAtMs || 0); const text = String(item?.text || '').trim() || 'удалено'; row.innerHTML = ` Версия ${index + 1}
${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}

${text}

`; list.append(row); }); } root.querySelector('#thread-history-close')?.addEventListener('click', () => { root.innerHTML = ''; }); } function openEditMessageModal({ initialText = '', onSave, onDelete }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#thread-edit-text'); const errorEl = root.querySelector('#thread-edit-error'); if (textEl) textEl.value = String(initialText || ''); const close = () => { root.innerHTML = ''; }; root.querySelector('#thread-edit-cancel')?.addEventListener('click', close); root.querySelector('#thread-edit-save')?.addEventListener('click', async () => { const value = String(textEl?.value || '').trim(); if (!value) { errorEl.textContent = 'Введите текст сообщения.'; return; } try { await onSave(value); close(); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.'); } }); root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => { try { await onDelete(); close(); } catch (error) { 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'; card.classList.add('is-counters-visible'); const author = node?.authorLogin || 'автор'; const versions = Array.isArray(node?.versions) ? node.versions : []; const versionsTotal = Number(node?.versionsTotal || versions.length || 1); const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)'); const likes = Number(node?.likesCount || 0); const replies = Number(node?.repliesCount || 0); const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0; const msgSubType = Number(node?.msgSubType || 0); const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null; 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 = createThreadAvatar(author); 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}`; title.append(loginEl, numberEl); if (versionsTotal > 1) { const editedMarker = document.createElement('button'); editedMarker.type = 'button'; editedMarker.className = 'message-edited-marker'; editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; editedMarker.title = 'Открыть историю редактирования'; editedMarker.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openMessageHistoryModal({ title: `История #${localNumber}`, versions, }); }); title.append(editedMarker); } const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено'; const body = document.createElement('p'); body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`; body.textContent = isDeletedMessage ? 'Сообщение удалено' : 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 repostButton = document.createElement('button'); repostButton.type = 'button'; repostButton.className = 'channel-action-item thread-reply-btn'; repostButton.innerHTML = ` Репост `; repostButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); try { await handlers.onRepost(target); } catch (error) { handlers?.onActionError?.(error, 'repost'); } }); actions.append(likeButton, replyButton, repostButton, shareButton); if (repostTarget) { const originalButton = document.createElement('button'); originalButton.type = 'button'; originalButton.className = 'channel-action-item'; originalButton.innerHTML = ` Оригинал `; originalButton.addEventListener('click', (event) => { event.stopPropagation(); const ok = window.confirm('Перейти к оригинальному сообщению?'); if (!ok) return; const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName); if (!ownerLogin) return; handlers.navigate(makeShineMessageRoute({ ownerLogin, messageBlockchainName: repostTarget.blockchainName, messageBlockNumber: repostTarget.blockNumber, })); }); actions.append(originalButton); } if (isOwnMessage) { const editButton = document.createElement('button'); editButton.type = 'button'; editButton.className = 'channel-action-item'; editButton.setAttribute('aria-label', 'Редактировать'); editButton.title = 'Редактировать'; editButton.innerHTML = ` `; editButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openEditMessageModal({ initialText: String(text || '').trim() === 'удалено' ? '' : text, onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }), onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }), }); }); actions.append(editButton); } card.append(actions); authorTile.addEventListener('click', (event) => { event.stopPropagation(); const login = String(node?.authorLogin || '').trim(); if (!login) return; handlers.navigate(makeProfileRoute(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 header = renderHeader({ title: '', leftAction: { label: '<', onClick: () => navigateBack() }, rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }], }); const threadHeaderButton = header.querySelector('.header-actions .icon-btn'); if (threadHeaderButton) { threadHeaderButton.classList.add('channel-header-route-btn'); threadHeaderButton.disabled = true; } 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(); }, onRepost: async (target) => { const { login, storagePwd } = requireSigningSession(); const feed = await authService.listSubscriptionsFeed(login, 1000); const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []) .map((row) => { const selectorRow = { ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber), channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash), }; if (!selectorRow.ownerBlockchainName || !Number.isFinite(selectorRow.channelRootBlockNumber) || selectorRow.channelRootBlockNumber < 0) { return null; } return { ownerLogin: String(row?.channel?.ownerLogin || '').trim(), channelName: String(row?.channel?.channelName || '').trim(), selector: selectorRow, }; }) .filter(Boolean); if (!channels.length) throw new Error('У вас пока нет каналов для репоста.'); openRepostModal({ navigate, channels, onSubmit: async ({ channel, text }) => { await authService.addBlockRepost({ login, storagePwd, channel, message: target, text, }); softHaptic(12); showToast('Репост опубликован'); showStatus(''); }, }); }, 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' ? 'Не удалось убрать лайк.' : action === 'repost' ? 'Не удалось сделать репост.' : 'Не удалось поставить лайк.'; showStatus(toUserMessage(error, fallback)); }, onEdit: async (target, textValue, meta = {}) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockEditMessage({ login, storagePwd, message: target, text: textValue, isChannelPost: meta?.isChannelPost === true, channel: selector?.channel || null, }); softHaptic(12); showToast('Сообщение обновлено'); showStatus(''); rerender(); }, }; screen.append(header, 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() || 'неизвестно'; const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel; if (threadHeaderButton) { threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`; threadHeaderButton.disabled = false; threadHeaderButton.onclick = (event) => { event.preventDefault(); animatePress(event.currentTarget); const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel); if (routeToChannel) navigate(routeToChannel); else navigate('channels-list'); }; } let seq = 0; const nextNumber = () => { seq += 1; return seq; }; let ancestorsWrap = null; if (ancestors.length) { 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())); }); } let focusWrap = null; if (focus) { 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())); } 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); } if (ancestorsWrap) { screen.append(ancestorsWrap); const divider = document.createElement('div'); divider.className = 'thread-history-divider'; screen.append(divider); } if (focusWrap) screen.append(focusWrap); screen.append(descendantsWrap); applyPendingScroll(screen, routeKey); const hasPendingScroll = pendingThreadScroll.has(routeKey); if (!hasPendingScroll && focusWrap) { setTimeout(() => { focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' }); }, 20); } } 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; }