import { renderHeader } from '../components/header.js'; import { authService, getMessageReactionState, 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'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); const revealedCountersByRoute = new Map(); const seenFlushTimersByRoute = new Map(); const seenPendingByRoute = new Map(); const firstUnreadJumpByRoute = 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 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 getRevealedCounterSet(routeKey) { const key = String(routeKey || '').trim(); if (!key) return new Set(); let bucket = revealedCountersByRoute.get(key); if (!bucket) { bucket = new Set(); revealedCountersByRoute.set(key, bucket); } return bucket; } function isCounterVisible(routeKey, counterKey) { const key = String(counterKey || '').trim(); if (!key) return false; return getRevealedCounterSet(routeKey).has(key); } function revealCounter(routeKey, counterKey) { const key = String(counterKey || '').trim(); if (!key) return; getRevealedCounterSet(routeKey).add(key); } function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); url.hash = `#/${cleanRoute}`; return url.toString(); } function channelDescriptionParamKey(selector) { const owner = String(selector?.ownerBlockchainName || '').trim(); const rootNo = Number(selector?.channelRootBlockNumber); const rootHash = normalizeRouteHash(selector?.channelRootBlockHash); if (!owner || !Number.isFinite(rootNo)) return ''; return `channel_desc:${owner}:${rootNo}:${rootHash}`; } function parseDescriptionOverride(payload) { if (!payload || typeof payload !== 'object') { return { hasOverride: false, description: '' }; } const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim(); if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) { return { hasOverride: false, description: '' }; } if (!rawValue) { return { hasOverride: true, description: '' }; } try { const parsed = JSON.parse(rawValue); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const value = typeof parsed.v === 'string' ? parsed.v : ''; return { hasOverride: true, description: value.trim() }; } } catch { // legacy raw string value } return { hasOverride: true, description: rawValue }; } function buildSelectorFromRoute(route, channelId) { const params = route?.params || {}; 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 ''; 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 }) { 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); 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 }) { 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); 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 openEditDescriptionModal({ initialValue = '', onSubmit }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#channel-description-text'); const counterEl = root.querySelector('#channel-description-counter'); const errorEl = root.querySelector('#channel-description-error'); const submitEl = root.querySelector('#channel-description-submit'); const cancelEl = root.querySelector('#channel-description-cancel'); let inFlight = false; const compute = () => { const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim(); const bytes = new TextEncoder().encode(value).length; const ok = bytes <= 200; return { value, bytes, ok, error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.', }; }; const setBusy = (busy) => { inFlight = !!busy; submitEl.disabled = inFlight; cancelEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить'; }; const close = () => { root.innerHTML = ''; }; const updateValidation = () => { const check = compute(); counterEl.textContent = `${check.bytes} / 200 байт`; errorEl.textContent = check.error; submitEl.disabled = inFlight || !check.ok; return check; }; cancelEl?.addEventListener('click', close); textEl?.addEventListener('input', updateValidation); submitEl?.addEventListener('click', async () => { if (inFlight) return; const check = updateValidation(); if (!check.ok) return; setBusy(true); errorEl.textContent = ''; try { await onSubmit(check.value); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.'); } }); if (textEl) { textEl.value = String(initialValue || ''); textEl.focus(); } updateValidation(); } 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), viewCount: Number(message?.viewCount || 0), seenByMe: message?.seenByMe === true, timestampMs: resolveMessageTimestampMs(message), messageRef, reactionState: messageRef ? getMessageReactionState(messageRef) : '', }; } async function loadFromApi(route, channelId) { const selector = buildSelectorFromRoute(route, channelId); 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 : []; const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1)); const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName); const unreadFromPayload = Number(payload.unreadCount || 0); const readDescription = async () => { const sourceDescription = String(payload.channel?.channelDescription || '').trim(); const paramKey = channelDescriptionParamKey(selector); if (!ownerLogin || !paramKey) return sourceDescription; try { const paramPayload = await authService.getUserParam(ownerLogin, paramKey); const override = parseDescriptionOverride(paramPayload); return override.hasOverride ? override.description : sourceDescription; } catch { return sourceDescription; } }; const resolvedDescription = await readDescription(); return { channel: { name: payload.channel?.channelName || 'неизвестный канал', displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`, description: resolvedDescription, ownerName: ownerLogin || 'неизвестно', }, posts, unreadCount: Number.isFinite(unreadFromPayload) ? Math.max(0, unreadFromPayload) : posts.filter((post) => post.seenByMe !== true).length, firstUnreadKey, isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(), 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, routeKey, 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; const views = document.createElement('p'); views.className = 'channel-message-views'; views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`; card.append(topRow, body, views); const refKey = messageRefKey(post.messageRef); if (refKey) { card.dataset.messageKey = refKey; } const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true; if (!countersVisible) { card.classList.remove('is-counters-visible'); } else { card.classList.add('is-counters-visible'); } const revealCounters = () => { if (!refKey) return; revealCounter(routeKey, refKey); card.classList.add('is-counters-visible'); }; card.addEventListener('click', revealCounters); if (!post.messageRef || !selector) return card; const actionKey = makeReactionActionKey(post.messageRef); const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; const actions = document.createElement('div'); actions.className = 'channel-message-actions'; 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; revealCounters(); 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 = ` Отразить `; replyButton.addEventListener('click', (event) => { animatePress(event.currentTarget); revealCounters(); openReplyModal({ onSubmit: async (text) => onReply(post.messageRef, text), }); }); const openThreadButton = document.createElement('button'); openThreadButton.type = 'button'; openThreadButton.className = 'channel-action-item channel-action-thread'; openThreadButton.innerHTML = ` Тред ${post.repliesCount || 0} `; openThreadButton.addEventListener('click', (event) => { animatePress(event.currentTarget); revealCounters(); 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); revealCounters(); const route = buildThreadRoute(post.messageRef, selector); await onShare(route); }); actions.append(likeButton, replyButton, 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); if (channelData.isOwnChannel) { const editButton = document.createElement('button'); editButton.type = 'button'; editButton.className = 'secondary-btn small-btn'; editButton.textContent = '✎'; editButton.title = 'Редактировать описание'; editButton.addEventListener('click', (event) => { animatePress(event.currentTarget); openEditDescriptionModal({ initialValue: channelData.channel.description || '', onSubmit: async (nextValue) => handlers.onEditDescription(nextValue), }); }); headActions.append(editButton); } head.append(title); head.append(owner, headActions); 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 unreadDivider = document.createElement('div'); unreadDivider.className = 'channels-unread-divider'; unreadDivider.textContent = 'Непрочитанные сообщения'; const unreadJump = document.createElement('button'); unreadJump.type = 'button'; unreadJump.className = 'channels-unread-jump'; unreadJump.innerHTML = ` `; const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge'); const postsByKey = new Map(); const unreadKeys = new Set(); let seenFlushInFlight = false; let seenObserver = null; if (channelData.posts.length) { channelData.posts.forEach((post) => { const row = renderPostCard(post, { navigate, routeKey, selector: channelData.selector, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onShare: handlers.onShare, }); const key = messageRefKey(post.messageRef); if (key) { postsByKey.set(key, post); if (post.seenByMe !== true) unreadKeys.add(key); } feed.append(row); }); } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Ждем ваших начинаний'; feed.append(empty); } const syncUnreadState = () => { unreadKeys.clear(); postsByKey.forEach((post, key) => { if (post.seenByMe !== true) unreadKeys.add(key); }); }; const updateUnreadJump = () => { const unreadCount = unreadKeys.size; unreadJump.classList.toggle('is-visible', unreadCount > 0); unreadJump.hidden = unreadCount <= 0; if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : ''; }; const mountUnreadDivider = () => { unreadDivider.remove(); if (!unreadKeys.size) return; const firstUnread = channelData.posts.find((post) => { const key = messageRefKey(post.messageRef); return key && unreadKeys.has(key); }); const firstUnreadKey = messageRefKey(firstUnread?.messageRef); if (!firstUnreadKey) return; const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`); if (target) { feed.insertBefore(unreadDivider, target); } }; const routePending = (() => { let bucket = seenPendingByRoute.get(routeKey); if (!bucket) { bucket = new Set(); seenPendingByRoute.set(routeKey, bucket); } return bucket; })(); const scheduleSeenFlush = () => { const oldTimer = seenFlushTimersByRoute.get(routeKey); if (oldTimer) clearTimeout(oldTimer); const timer = setTimeout(async () => { seenFlushTimersByRoute.delete(routeKey); if (seenFlushInFlight) return; const pendingKeys = [...routePending].filter((key) => { const post = postsByKey.get(key); return !!post && post.seenByMe !== true; }); if (!pendingKeys.length) return; const refs = pendingKeys .map((key) => parseMessageRefKey(key)) .filter(Boolean); if (!refs.length) return; pendingKeys.forEach((key) => routePending.delete(key)); seenFlushInFlight = true; try { await handlers.onMarkSeenBatch(refs); refs.forEach((ref) => { const key = messageRefKey(ref); const post = key ? postsByKey.get(key) : null; if (post) post.seenByMe = true; }); syncUnreadState(); mountUnreadDivider(); updateUnreadJump(); } catch (error) { refs.forEach((ref) => { const key = messageRefKey(ref); if (!key) return; const node = feed.querySelector(`[data-message-key="${key}"]`); if (node) seenObserver?.observe(node); }); handlers.onSeenError?.(error); } finally { seenFlushInFlight = false; if (routePending.size) scheduleSeenFlush(); } }, 220); seenFlushTimersByRoute.set(routeKey, timer); }; unreadJump.addEventListener('click', () => { const unreadPosts = channelData.posts.filter((post) => { const key = messageRefKey(post.messageRef); return key && unreadKeys.has(key); }); const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1]; const key = messageRefKey(targetPost?.messageRef); if (!key) return; const target = feed.querySelector(`[data-message-key="${key}"]`); target?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }); if (channelData.isOwnChannel) { actionButton.addEventListener('click', (event) => { animatePress(event.currentTarget); openAddMessageModal({ channelName: channelData.channel.name, onSubmit: async (bodyText) => handlers.onAddPost(bodyText), }); }); } else { actionButton.addEventListener('click', handlers.onUnfollowChannel); } const backButton = document.createElement('button'); backButton.className = 'secondary-btn channel-back-btn'; backButton.textContent = 'Назад к каналам'; backButton.addEventListener('click', () => navigate('channels-list')); screen.append(head, actionButton, feed, backButton, unreadJump); if (state.session.login && channelData.selector && channelData.posts.length) { seenObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return; const key = String(entry.target?.dataset?.messageKey || '').trim(); if (!key) return; const post = postsByKey.get(key); if (!post || post.seenByMe === true) return; routePending.add(key); seenObserver?.unobserve(entry.target); }); if (routePending.size) scheduleSeenFlush(); }, { root: document.getElementById('app-screen') || null, threshold: [0.6], }); feed.querySelectorAll('[data-message-key]').forEach((node) => { const key = String(node.dataset.messageKey || '').trim(); if (key && unreadKeys.has(key)) seenObserver?.observe(node); }); } syncUnreadState(); mountUnreadDivider(); updateUnreadJump(); const firstUnreadCandidate = channelData.firstUnreadKey || (() => { const first = channelData.posts.find((post) => post.seenByMe !== true); return messageRefKey(first?.messageRef); })(); if (firstUnreadCandidate) { const previous = firstUnreadJumpByRoute.get(routeKey); if (previous !== firstUnreadCandidate) { pendingScrollByRoute.set(routeKey, firstUnreadCandidate); firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate); } } else { firstUnreadJumpByRoute.delete(routeKey); } applyPendingScroll(screen, routeKey); return () => { seenObserver?.disconnect(); const timer = seenFlushTimersByRoute.get(routeKey); if (timer) clearTimeout(timer); seenFlushTimersByRoute.delete(routeKey); seenPendingByRoute.delete(routeKey); }; } 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); }; const requireSigningSession = () => { const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { 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 onMarkSeenBatch = async (refs) => { if (!Array.isArray(refs) || !refs.length) return; const login = String(state.session.login || '').trim(); if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return; await authService.markChannelMessagesSeen({ login, channel: { ownerBlockchainName: routeSelector.ownerBlockchainName, channelRootBlockNumber: routeSelector.channelRootBlockNumber, channelRootBlockHash: routeSelector.channelRootBlockHash, }, messages: refs, }); }; 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 (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockTextPost({ login, storagePwd, channel: routeSelector, text: bodyText, }); pendingScrollByRoute.set(routeKey, '__LAST__'); softHaptic(15); showToast('Сообщение отправлено'); rerender(); }; const onEditDescription = async (descriptionText) => { const { login, storagePwd } = requireSigningSession(); const selector = routeSelector; const param = channelDescriptionParamKey(selector); if (!param) throw new Error('Идентификатор канала не готов для обновления описания.'); const value = JSON.stringify({ v: String(descriptionText || '').trim() }); await authService.addBlockUserParam({ login, storagePwd, param, value, }); softHaptic(10); 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); 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, onEditDescription: async (descriptionText) => { try { await onEditDescription(descriptionText); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось сохранить описание.')); } }, onUnfollowChannel: async (event) => { animatePress(event?.currentTarget); try { const { login, storagePwd } = requireSigningSession(); if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.'); await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true, }); softHaptic(15); showToast('Отписка от канала выполнена'); navigate('channels-list'); } catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); } }, onMarkSeenBatch: async (refs) => { try { await onMarkSeenBatch(refs); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.')); } }, onSeenError: (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; }