import { renderHeader } from '../components/header.js'; import { authService, getMessageReactionState, setChannelsFeed, setMessageReactionState, state, } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { animatePress, createSkeletonCard, formatRelativeTime, longPressFeel, shareOrCopyLink, showToast, softHaptic, } from '../services/channels-ux.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; const CHANNEL_TYPE_PERSONAL = 100; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); function isChannelsDemoMode() { try { const qs = new URLSearchParams(window.location.search); if (qs.get('channelsDemo') === '1') return true; return localStorage.getItem('shine-channels-demo') === '1'; } catch { return false; } } 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 parseMessageRefKey(key) { const raw = String(key || '').trim(); if (!raw) return null; const parts = raw.split(':'); if (parts.length !== 3) return null; const blockNumber = Number(parts[1]); const blockHash = normalizeMessageHash(parts[2]); if (!parts[0] || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; return { blockchainName: parts[0], blockNumber, blockHash, }; } function blockRefToMessageKey(blockRef, fallbackBch = '') { const blockNumber = toSafeInt(blockRef?.blockNumber); const blockHash = normalizeMessageHash(blockRef?.blockHash); const blockchainName = String(fallbackBch || '').trim(); if (!blockchainName || blockNumber == null || !blockHash) return ''; return `${blockchainName}:${blockNumber}:${blockHash}`; } function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); url.hash = `#/${cleanRoute}`; return url.toString(); } function buildSelectorFromRoute(route, channelId) { const params = route?.params || {}; if (params.ownerBlockchainName && params.channelName) { return { ownerBlockchainName: String(params.ownerBlockchainName || '').trim(), channelName: String(params.channelName || '').trim(), }; } if (params.ownerBlockchainName) { const rootBlockNumber = toSafeInt(params.channelRootBlockNumber); if (rootBlockNumber != null) { return { ownerBlockchainName: String(params.ownerBlockchainName), channelRootBlockNumber: rootBlockNumber, channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash), }; } } const summary = channelId ? state.channelsIndex[channelId] : null; if (!summary) return null; return { ownerBlockchainName: summary.channel?.ownerBlockchainName, channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber, channelRootBlockHash: normalizeRouteHash(summary.channel?.channelRoot?.blockHash), }; } function buildThreadRoute(messageRef, selector) { if (!messageRef || !selector) return ''; const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim(); const channelName = String(selector.channelName || '').trim(); if (ownerBlockchainName && channelName) { return [ 'channel', encodeRoutePart(ownerBlockchainName), encodeRoutePart(channelName), messageRef.blockNumber, ].join('/'); } return [ 'channel-thread-view', encodeRoutePart(messageRef.blockchainName), messageRef.blockNumber, normalizeRouteHash(messageRef.blockHash), encodeRoutePart(selector.ownerBlockchainName), selector.channelRootBlockNumber, normalizeRouteHash(selector.channelRootBlockHash), ].join('/'); } 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 resolveMessageText(message) { return firstNonEmptyText( message?.text, message?.message, message?.body, latestVersionText(message?.versions), ); } function toTimestampMs(...candidates) { for (const candidate of candidates) { if (candidate == null) continue; if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) { return candidate > 1e12 ? Math.round(candidate) : Math.round(candidate * 1000); } if (typeof candidate === 'string') { const trimmed = candidate.trim(); if (!trimmed) continue; const asNum = Number(trimmed); if (Number.isFinite(asNum) && asNum > 0) { return asNum > 1e12 ? Math.round(asNum) : Math.round(asNum * 1000); } const parsed = Date.parse(trimmed); if (Number.isFinite(parsed) && parsed > 0) return parsed; } } return 0; } function resolveMessageTimestampMs(message) { return toTimestampMs( message?.messageTimeMs, message?.message_time_ms, message?.timeMs, message?.time_ms, message?.timestampMs, message?.timestamp_ms, message?.createdAtMs, message?.created_at_ms, message?.messageTime, message?.createdAt, message?.created_at, message?.timestamp, ); } function openAboutChannelModal(channel) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; root.querySelector('#about-channel-close')?.addEventListener('click', () => { root.innerHTML = ''; }); } function openReplyModal({ onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#reply-text'); const errorEl = root.querySelector('#reply-error'); const submitEl = root.querySelector('#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('#reply-cancel')?.addEventListener('click', close); root.querySelector('#reply-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 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 openAddMessageModal({ channelName, onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#channel-message-text'); const errorEl = root.querySelector('#channel-message-error'); const submitEl = root.querySelector('#channel-message-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('#channel-message-cancel')?.addEventListener('click', close); root.querySelector('#channel-message-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 body = String(textEl?.value || '').trim(); if (!body) { errorEl.textContent = 'Введите текст сообщения.'; return; } setBusy(true); errorEl.textContent = ''; try { await onSubmit(body); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.'); } }); if (textEl) textEl.focus(); } function mapApiMessageToPost(message, selector, localNumber) { const blockNumber = toSafeInt(message?.messageRef?.blockNumber); const blockHash = normalizeMessageHash(message?.messageRef?.blockHash); const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim(); const hasRef = !!(messageBch && blockNumber != null && blockHash); const resolvedText = resolveMessageText(message); const messageRef = hasRef ? { blockchainName: messageBch, blockNumber, blockHash, } : null; if (messageRef) { setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked'); } return { localNumber, authorLogin: message?.authorLogin || 'автор', body: resolvedText || '(пусто)', likesCount: Number(message?.likesCount || 0), repliesCount: Number(message?.repliesCount || 0), timestampMs: resolveMessageTimestampMs(message), messageRef, reactionState: messageRef ? getMessageReactionState(messageRef) : '', }; } async function loadFromApi(route, channelId) { let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); return cachedFeed; }; const getAllRows = async () => { const feed = await ensureFeed(); return [ ...(Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []), ...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []), ...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []), ]; }; let selector = buildSelectorFromRoute(route, channelId); if (selector?.ownerBlockchainName && selector?.channelName) { const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); const allRows = await getAllRows(); let channel = allRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); if (!channel) { channel = allRows.find((item) => ( String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); } if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) { try { const ownerUser = await authService.getUser(routeOwnerRaw); 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() === selector.channelName.toLowerCase() )); } } catch { // ignore fallback lookup failures } } if (!channel && routeOwnerLoginFromBch) { try { const ownerFeed = await authService.listSubscriptionsFeed(routeOwnerLoginFromBch, 500); const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; channel = ownerRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); } catch { // ignore owner feed lookup failures } } if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { throw new Error('Канал не найден.'); } selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash), channelName: selector.channelName, }; } if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) { throw new Error('Не удалось определить канал из адреса страницы.'); } const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login); const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; const currentLogin = String(state.session.login || '').trim(); const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const channelName = String(payload.channel?.channelName || '').trim(); const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1); const canResolveReverse = ( channelTypeCode === CHANNEL_TYPE_PERSONAL && !!currentLogin && !!ownerLogin && !!channelName && ownerLogin.toLowerCase() === currentLogin.toLowerCase() ); if (canResolveReverse) { const allRows = await getAllRows(); const reverseSummary = allRows.find((item) => ( Number(item?.channel?.channelTypeCode ?? 1) === CHANNEL_TYPE_PERSONAL && String(item?.channel?.ownerLogin || '').trim().toLowerCase() === channelName.toLowerCase() && String(item?.channel?.channelName || '').trim().toLowerCase() === currentLogin.toLowerCase() )); if (reverseSummary?.channel?.ownerBlockchainName && reverseSummary?.channel?.channelRoot?.blockNumber != null) { const reverseSelector = { ownerBlockchainName: String(reverseSummary.channel.ownerBlockchainName), channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash), }; const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login); const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : []; mergedMessages = mergedMessages.concat(reverseMessages); } else { reverseChannelMissingWarning = `У собеседника ${channelName} пока не создан ответный персональный чат.`; } } const posts = mergedMessages .map((message, index) => mapApiMessageToPost(message, selector, index + 1)) .sort((a, b) => { const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0); if (byTime !== 0) return byTime; const aNum = Number(a?.messageRef?.blockNumber || 0); const bNum = Number(b?.messageRef?.blockNumber || 0); return aNum - bNum; }) .map((post, index) => ({ ...post, localNumber: index + 1 })); const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(); const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const isSubscribed = followedRows.some((row) => ( String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '') && Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber) && normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash) )); return { channel: { name: payload.channel?.channelName || 'неизвестный канал', displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`, description: String(payload.channel?.channelDescription || '').trim(), ownerName: ownerLogin || 'неизвестно', }, posts, reverseChannelMissingWarning, isOwnChannel, isSubscribed, selector, }; } function renderLoadError(screen, navigate, message, onRetry) { const card = document.createElement('div'); card.className = 'card stack channels-status'; card.innerHTML = ` Не удалось загрузить канал

${message || 'Проверьте подключение к серверу и повторите попытку.'}

`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'primary-btn'; retry.textContent = 'Повторить'; retry.addEventListener('click', onRetry); const back = document.createElement('button'); back.type = 'button'; back.className = 'secondary-btn'; back.textContent = 'Назад к каналам'; back.addEventListener('click', () => navigate('channels-list')); card.append(retry, back); screen.append(card); } function renderDemoFallback(screen, navigate, error) { const info = document.createElement('div'); info.className = 'card stack'; info.innerHTML = ` Включен демо-режим

Данные канала с сервера недоступны. Показан демо-контент.

${toUserMessage(error, 'Ошибка API/WS')}

`; screen.append(info); const back = document.createElement('button'); back.className = 'secondary-btn'; back.textContent = 'Назад к каналам'; back.addEventListener('click', () => navigate('channels-list')); screen.append(back); } function applyPendingScroll(screen, routeKey) { const target = pendingScrollByRoute.get(routeKey); if (!target) return; const doScroll = () => { if (target === '__LAST__') { const cards = screen.querySelectorAll('[data-message-key]'); const last = cards[cards.length - 1]; if (last) { last.scrollIntoView({ behavior: 'smooth', block: 'center' }); } pendingScrollByRoute.delete(routeKey); return; } const element = screen.querySelector(`[data-message-key="${target}"]`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); pendingScrollByRoute.delete(routeKey); } }; setTimeout(doScroll, 20); } function renderPostCard(post, { navigate, selector, onToggleLike, onReply, onShare, }) { const card = document.createElement('article'); card.className = 'card stack channel-message-card'; const topRow = document.createElement('div'); topRow.className = 'channel-message-top'; const avatar = document.createElement('div'); avatar.className = 'channel-message-avatar'; avatar.textContent = String(post.authorLogin || '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 = post.authorLogin; const numberEl = document.createElement('span'); numberEl.className = 'author-line-num'; numberEl.textContent = `· #${post.localNumber}`; const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—'; title.append(loginEl, numberEl); authorBlock.append(title, timestamp); topRow.append(avatar, authorBlock); const body = document.createElement('p'); body.className = 'channel-message-body'; body.textContent = post.body; card.append(topRow, body); const refKey = messageRefKey(post.messageRef); if (refKey) { card.dataset.messageKey = refKey; } card.classList.add('is-counters-visible'); if (!post.messageRef || !selector) return card; const actions = document.createElement('div'); actions.className = 'channel-message-actions'; const actionKey = makeReactionActionKey(post.messageRef); const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; const likeButton = document.createElement('button'); likeButton.type = 'button'; likeButton.className = 'channel-action-item channel-action-like'; const isLiked = post.reactionState === 'liked'; if (isLiked) likeButton.classList.add('is-liked'); likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} ${post.likesCount || 0} `; likeButton.disabled = isPending; likeButton.addEventListener('click', async (event) => { animatePress(event.currentTarget); if (isPending) return; if (!isLiked) { const ok = window.confirm('Поставить лайк?'); if (!ok) return; } await longPressFeel(event.currentTarget, 130); likeButton.disabled = true; const labelEl = likeButton.querySelector('.channel-action-label'); if (labelEl) labelEl.textContent = 'Лайк...'; await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); }); const replyButton = document.createElement('button'); replyButton.type = 'button'; replyButton.className = 'channel-action-item channel-action-reply'; replyButton.innerHTML = ` Ответить ${post.repliesCount || 0} `; replyButton.addEventListener('click', (event) => { animatePress(event.currentTarget); openReplyModal({ navigate, onSubmit: async (text) => onReply(post.messageRef, text), }); }); actions.append(likeButton, replyButton); const openThreadButton = document.createElement('button'); openThreadButton.type = 'button'; openThreadButton.className = 'channel-action-item channel-action-thread'; openThreadButton.innerHTML = ` Тред `; openThreadButton.addEventListener('click', (event) => { animatePress(event.currentTarget); const route = buildThreadRoute(post.messageRef, selector); if (route) navigate(route); }); const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'channel-action-item channel-action-share'; shareButton.innerHTML = ` Отправить `; shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); const route = buildThreadRoute(post.messageRef, selector); await onShare(route); }); actions.append(openThreadButton, shareButton); card.append(actions); return card; } function renderBody(screen, navigate, routeKey, channelData, handlers) { const head = document.createElement('div'); head.className = 'card channel-head-card'; const title = document.createElement('strong'); title.className = 'channel-head-title'; title.textContent = String(channelData.channel.name || '').trim(); const owner = document.createElement('p'); owner.className = 'channel-head-meta'; owner.textContent = `Владелец: ${channelData.channel.ownerName}`; const headActions = document.createElement('div'); headActions.className = 'channel-head-actions'; const aboutButton = document.createElement('button'); aboutButton.type = 'button'; aboutButton.className = 'secondary-btn small-btn'; aboutButton.textContent = 'О канале'; aboutButton.addEventListener('click', (event) => { animatePress(event.currentTarget); openAboutChannelModal(channelData.channel); }); headActions.append(aboutButton); head.append(title); head.append(owner, headActions); if (channelData.reverseChannelMissingWarning) { const reverseWarning = document.createElement('p'); reverseWarning.className = 'channel-head-meta'; reverseWarning.textContent = channelData.reverseChannelMissingWarning; head.append(reverseWarning); } const actionButton = document.createElement('button'); actionButton.className = channelData.isOwnChannel ? 'primary-btn channel-main-action' : 'destructive-btn channel-main-action'; actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал'; const feed = document.createElement('div'); feed.className = 'stack channel-feed'; const postsByKey = new Map(); if (channelData.posts.length) { channelData.posts.forEach((post) => { const row = renderPostCard(post, { navigate, selector: channelData.selector, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onShare: handlers.onShare, }); const key = messageRefKey(post.messageRef); if (key) { postsByKey.set(key, post); } feed.append(row); }); } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Ждем ваших начинаний'; feed.append(empty); } if (channelData.isOwnChannel) { actionButton.addEventListener('click', (event) => { animatePress(event.currentTarget); openAddMessageModal({ channelName: channelData.channel.name, navigate, onSubmit: async (bodyText) => handlers.onAddPost(bodyText), }); }); } else if (!channelData.isSubscribed) { actionButton.addEventListener('click', handlers.onSubscribeChannel); } const backButton = document.createElement('button'); backButton.className = 'secondary-btn channel-back-btn'; backButton.textContent = 'Назад к каналам'; backButton.addEventListener('click', () => navigate('channels-list')); if (channelData.isOwnChannel || !channelData.isSubscribed) { screen.append(head, actionButton, feed, backButton); } else { screen.append(head, feed, backButton); } applyPendingScroll(screen, routeKey); return () => { // noop }; } 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 channelId = route.params.channelId || ''; const routeSelector = buildSelectorFromRoute(route, channelId); const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`; 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'); const statusBox = document.createElement('div'); statusBox.className = 'card status-line is-unavailable channels-status'; statusBox.style.display = 'none'; const showStatus = (message) => { if (!message) { statusBox.style.display = 'none'; statusBox.textContent = ''; return; } statusBox.textContent = message; statusBox.style.display = ''; }; const rerender = () => { const current = document.querySelector('section.channels-screen--channel'); if (!current) return; const next = render({ navigate, route }); current.replaceWith(next); }; let activeSelector = null; const requireSigningSession = () => { const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { state.authReturnHash = window.location.hash || '#/channels-list'; navigate('login-view'); throw new Error('Для этого действия нужно войти'); } return { login, storagePwd }; }; const onToggleLike = async (messageRef, action) => { const actionKey = makeReactionActionKey(messageRef); if (!actionKey) { throw new Error('Некорректная ссылка на сообщение для реакции.'); } if (pendingReactionActions.has(actionKey)) return; const previousReaction = getMessageReactionState(messageRef); const nextReaction = action === 'unlike' ? 'unliked' : 'liked'; pendingReactionActions.add(actionKey); try { const { login, storagePwd } = requireSigningSession(); if (action === 'unlike') { await authService.addBlockUnlike({ login, storagePwd, message: messageRef }); } else { await authService.addBlockLike({ login, storagePwd, message: messageRef }); } setMessageReactionState(messageRef, nextReaction); softHaptic(10); rerender(); } catch (error) { setMessageReactionState(messageRef, previousReaction || 'unliked'); rerender(); throw error; } finally { pendingReactionActions.delete(actionKey); } }; const onReply = async (messageRef, text) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockReply({ login, storagePwd, message: messageRef, text }); const scrollTarget = messageRefKey(messageRef); if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget); softHaptic(15); showToast('Ответ отправлен'); rerender(); }; const onShare = async (routePath) => { try { const routeToShare = String(routePath || '').trim(); if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.'); const result = await shareOrCopyLink({ title: 'SHiNE · Каналы', text: 'Тред из канала SHiNE', url: buildAbsoluteRouteUrl(routeToShare), }); if (result === 'copied') showToast('Ссылка скопирована'); if (result === 'shared') showToast('Ссылка передана'); if (result === 'shared' || result === 'copied') softHaptic(10); } catch (error) { showStatus(toUserMessage(error, 'Не удалось отправить ссылку.')); } }; const onAddPost = async (bodyText) => { const { login, storagePwd } = requireSigningSession(); if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockTextPost({ login, storagePwd, channel: activeSelector, text: bodyText, }); pendingScrollByRoute.set(routeKey, '__LAST__'); softHaptic(15); showToast('Сообщение отправлено'); rerender(); }; screen.append( renderHeader({ title: '', leftAction: { label: '<', onClick: () => navigate('channels-list') }, }), ); screen.append(statusBox); const skeleton = renderSkeleton(screen); let cleanupSeenTracking = null; (async () => { try { const apiData = await loadFromApi(route, channelId); activeSelector = apiData?.selector || null; skeleton.remove(); cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { onToggleLike: async (messageRef, action) => { try { await onToggleLike(messageRef, action); showStatus(''); } catch (error) { showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.')); } }, onReply: async (messageRef, text) => { try { await onReply(messageRef, text); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось отправить ответ.')); } }, onAddPost: async (bodyText) => { try { await onAddPost(bodyText); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); } }, onShare: onShare, onSubscribeChannel: async (event) => { animatePress(event?.currentTarget); try { const { login, storagePwd } = requireSigningSession(); if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.'); const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`; const ok = window.confirm(`Подписаться на канал ${targetName}?`); if (!ok) return; await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: false, }); const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); softHaptic(15); showToast('Подписка на канал выполнена'); rerender(); } catch (error) { showStatus(toUserMessage(error, 'Не удалось подписаться на канал.')); } }, }); } catch (error) { skeleton.remove(); if (isChannelsDemoMode()) { renderDemoFallback(screen, navigate, error); return; } renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender); } })(); screen.cleanup = () => { appScreen?.classList.remove('channels-scroll-clean'); if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking(); }; return screen; }