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 { renderAvatar, renderUserAvatar } from '../components/avatar-image.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { composeMessageWithAttachments, createAttachmentCarouselElement, escapeHtml, MAX_MESSAGE_ATTACHMENTS, parseMessageAttachments, } from '../services/attachment-format.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineMessageRoute, makeShineChannelAboutRoute, } from '../services/shine-routes.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; const MSG_SUBTYPE_TEXT_RATING = 30; const MSG_SUBTYPE_TEXT_REPOST = 50; const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100; const MSG_SUBTYPE_TEXT_EXERCISE = 110; const MSG_SUBTYPE_TEXT_SERVICE = 120; const MSG_SUBTYPE_TEXT_COURSE = 130; const MSG_SUBTYPE_STATUS_DONE_ONCE = 10; const MSG_SUBTYPE_STATUS_LEARNED = 20; const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30; const MSG_SUBTYPE_STATUS_CONFIRMED = 100; const MSG_SUBTYPE_STATUS_INTERESTED = 110; const MSG_SUBTYPE_STATUS_STARTED = 120; const MSG_SUBTYPE_STATUS_IN_STUDY = 130; const MSG_SUBTYPE_STATUS_ABANDONED = 140; const MSG_SUBTYPE_STATUS_COMPLETED = 150; const DIARY_CHANNEL_NAME = 'diary'; const DIARY_CHANNEL_DISPLAY_NAME = 'Дневник'; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); const messageAvatarSnapshotCache = new Map(); const messageAvatarPendingByLogin = new Map(); async function loadMessageAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; const key = cleanLogin.toLowerCase(); if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key); if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key); const pending = loadProfileSnapshot(cleanLogin) .then((snapshot) => { messageAvatarSnapshotCache.set(key, snapshot || null); messageAvatarPendingByLogin.delete(key); return snapshot || null; }) .catch(() => { messageAvatarSnapshotCache.set(key, null); messageAvatarPendingByLogin.delete(key); return null; }); messageAvatarPendingByLogin.set(key, pending); return pending; } function createMessageAvatar(login) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', size: 'sm', className: 'channel-message-avatar', title, }); if (!cleanLogin) return avatarEl; void loadMessageAvatarSnapshot(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: 'sm', className: 'channel-message-avatar', title, }); avatarEl.replaceWith(upgraded); }); return avatarEl; } 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 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.pathname = `/${cleanRoute}`; url.hash = ''; 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 ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName); return makeShineMessageRoute({ ownerLogin, messageBlockchainName: messageRef.blockchainName, messageBlockNumber: messageRef.blockNumber, }); } function buildChannelSettingsKey(ownerBlockchainName, channelName) { const ownerBch = String(ownerBlockchainName || '').trim(); const name = String(channelName || '').trim(); if (!ownerBch || !name) return ''; return `${ownerBch}/${name}`; } function getChannelScrollRoot() { return document.getElementById('app-screen'); } function scrollRootBy(delta, smooth = false) { const root = getChannelScrollRoot(); const behavior = smooth ? 'smooth' : 'auto'; if (root && typeof root.scrollBy === 'function') { root.scrollBy({ top: delta, behavior }); return; } window.scrollBy({ top: delta, behavior }); } function getUnreadAnchorViewportFraction(unreadCount = 0) { const count = Math.max(0, Number(unreadCount || 0)); if (count <= 1) return 0.68; if (count <= 3) return 0.56; if (count <= 7) return 0.48; return 0.42; } function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) { if (!element) return false; const root = getChannelScrollRoot(); const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0; const targetTop = Math.max(0, Math.round(viewportHeight * fraction)); const rect = element.getBoundingClientRect(); const delta = rect.top - targetTop; if (Math.abs(delta) < 2) return true; scrollRootBy(delta, smooth); return true; } function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) { return scrollElementToViewportFraction( screen.querySelector('.channel-unread-line'), getUnreadAnchorViewportFraction(unreadCount), smooth, ); } function createChannelReadTracker({ screen, routeKey, settingKey, unreadCount, messagesCount, initialSeenCount, }) { const login = String(state.session.login || '').trim(); const storagePwd = state.session.storagePwdInMemory; const canWrite = !!(settingKey && login && storagePwd); const safeMessagesCount = Math.max(0, Number(messagesCount || 0)); const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount)); const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null; const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount); let desiredSeenCount = safeInitialSeenCount; let persistedSeenCount = safeInitialSeenCount; let inFlight = false; let disposed = false; let rafId = 0; let timerId = 0; const clearTimer = () => { if (timerId) { clearTimeout(timerId); timerId = 0; } }; const queueFlush = (delayMs = 180) => { if (disposed || !canWrite) return; clearTimer(); timerId = setTimeout(() => { timerId = 0; void flush(); }, Math.max(0, Number(delayMs) || 0)); }; const flush = async () => { if (disposed || !canWrite) return; const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount)); if (next <= persistedSeenCount) return; if (inFlight) { queueFlush(120); return; } inFlight = true; try { await authService.upsertUserSetting({ login, settingType: 1, settingKey, timeMs: Date.now(), valueText: '', valueNum: next, storagePwd, }); persistedSeenCount = next; } catch { queueFlush(800); } finally { inFlight = false; } }; const collectSeenCount = () => { const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]')); if (!cards.length) return safeInitialSeenCount; if (!unreadLine) return safeMessagesCount; const root = getChannelScrollRoot(); const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0; const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction)); let seen = safeInitialSeenCount; for (const card of cards) { const localNumber = Number(card.dataset.localNumber || 0); if (!Number.isFinite(localNumber) || localNumber <= 0) continue; const rect = card.getBoundingClientRect(); if (rect.top > thresholdTop + 1) break; seen = Math.max(seen, localNumber); } return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount)); }; const measure = () => { if (disposed) return; if (rafId) return; rafId = window.requestAnimationFrame(() => { rafId = 0; const next = collectSeenCount(); if (next > desiredSeenCount) { desiredSeenCount = next; queueFlush(180); } }); }; const scrollRoot = getChannelScrollRoot(); const onScroll = () => measure(); const onResize = () => measure(); if (scrollRoot && typeof scrollRoot.addEventListener === 'function') { scrollRoot.addEventListener('scroll', onScroll, { passive: true }); } else { window.addEventListener('scroll', onScroll, { passive: true }); } window.addEventListener('resize', onResize); const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0; if (initialSyncRequired) { void authService.upsertUserSetting({ login, settingType: 1, settingKey, timeMs: Date.now(), valueText: '', valueNum: safeMessagesCount, storagePwd, }).catch(() => {}); persistedSeenCount = safeMessagesCount; desiredSeenCount = safeMessagesCount; } else { window.setTimeout(() => measure(), 120); } const cleanup = () => { disposed = true; clearTimer(); if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; } if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') { scrollRoot.removeEventListener('scroll', onScroll); } else { window.removeEventListener('scroll', onScroll); } window.removeEventListener('resize', onResize); }; return { cleanup, measure, }; } 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 isStoriesChannel(channel = null) { const typeCode = Number(channel?.channelTypeCode ?? channel?.channel?.channelTypeCode ?? 1); const name = String(channel?.channelName || channel?.channel?.channelName || '').trim().toLowerCase(); return typeCode === CHANNEL_TYPE_STORIES || name === 'stories'; } 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 getChannelMessageTypeMeta(msgSubType) { switch (Number(msgSubType || 0)) { case MSG_SUBTYPE_TEXT_EXERCISE: return { label: 'Упражнение', actionable: true }; case MSG_SUBTYPE_TEXT_SERVICE: return { label: 'Услуга', actionable: true }; case MSG_SUBTYPE_TEXT_COURSE: return { label: 'Курс', actionable: true }; case MSG_SUBTYPE_TEXT_ENTRYPOINT: return { label: 'Оглавление' }; default: return null; } } function isEntrypointSubType(msgSubType) { return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT; } function isDiarySelector(selector) { return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME; } function isStatusActionSubType(msgSubType) { return new Set([ MSG_SUBTYPE_STATUS_DONE_ONCE, MSG_SUBTYPE_STATUS_LEARNED, MSG_SUBTYPE_STATUS_SERVICE_PASSED, MSG_SUBTYPE_STATUS_CONFIRMED, MSG_SUBTYPE_STATUS_INTERESTED, MSG_SUBTYPE_STATUS_STARTED, MSG_SUBTYPE_STATUS_IN_STUDY, MSG_SUBTYPE_STATUS_ABANDONED, MSG_SUBTYPE_STATUS_COMPLETED, ]).has(Number(msgSubType || 0)); } function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) { const status = Number(statusSubType || 0); const target = Number(targetMsgSubType || 0); if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' }; if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' }; if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' }; if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' }; if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' }; if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' }; if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' }; if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' }; if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' }; return { label: 'Действие' }; } function getStatusActionOptionsForTarget(targetMsgSubType) { switch (Number(targetMsgSubType || 0)) { case MSG_SUBTYPE_TEXT_EXERCISE: return [ { subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' }, { subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' }, ]; case MSG_SUBTYPE_TEXT_SERVICE: return [ { subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' }, ]; case MSG_SUBTYPE_TEXT_COURSE: return [ { subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' }, { subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' }, { subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' }, { subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' }, { subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' }, ]; default: return []; } } function createChannelAvatarElement(channel, size = 96) { const txId = String(channel?.avaAr || '').trim(); const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К'; const wrap = renderAvatar({ initials: title.slice(0, 1).toUpperCase() || 'К', avatar: txId ? { ar: txId } : null, size: 'xl', className: 'channel-profile-avatar', title, alt: 'Аватар канала', }); wrap.style.setProperty('--channel-avatar-size', `${size}px`); return wrap; } function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null, changedAtMs = 0, } = {}) { const avatarState = String(channel.avaAr || '').trim() ? 'Установлен' : 'Не установлен'; const changedAtLabel = changedAtMs ? new Date(Number(changedAtMs)).toLocaleString('ru-RU') : '—'; const root = document.getElementById('modal-root'); root.innerHTML = ` `; root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 96)); root.querySelector('#about-channel-close')?.addEventListener('click', () => { root.innerHTML = ''; }); root.querySelector('#about-channel-edit')?.addEventListener('click', () => { root.innerHTML = ''; if (typeof onEdit === 'function') onEdit(); }); } function openAboutChannelModal(channel, options = {}) { openChannelMetaDetailsModal({ title: 'О канале', channel, canEdit: options.canEdit === true, onEdit: options.onEdit, changedAtMs: Number(channel?.metaUpdatedAtMs || 0), }); } function openEditChannelModal({ channel, onSave }) { const root = document.getElementById('modal-root'); let selectedAvatar = channel?.avaAr ? { name: 'Аватар', ar: channel.avaAr, sha256: channel.avaSha256, size: channel.avaSize, purpose: 'avatar', } : null; const renderAvatarPreview = () => { const slot = root.querySelector('#edit-channel-avatar-preview'); if (!slot) return; slot.innerHTML = ''; slot.append(createChannelAvatarElement({ ...channel, avaAr: selectedAvatar?.ar || '', displayTitle: root.querySelector('#edit-channel-title')?.value || channel.displayTitle, }, 86)); }; root.innerHTML = ` `; renderAvatarPreview(); root.querySelector('#edit-channel-cancel')?.addEventListener('click', () => { root.innerHTML = ''; }); root.querySelector('#edit-channel-title')?.addEventListener('input', renderAvatarPreview); root.querySelector('#edit-channel-avatar')?.addEventListener('click', async () => { try { const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, gateway: state.entrySettings.arweaveServer, mode: 'avatar', historyPurpose: 'avatar', }); if (!item) return; selectedAvatar = item; renderAvatarPreview(); } catch (error) { const errorEl = root.querySelector('#edit-channel-error'); if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.'); } }); root.querySelector('#edit-channel-save')?.addEventListener('click', async () => { const saveBtn = root.querySelector('#edit-channel-save'); const errorEl = root.querySelector('#edit-channel-error'); const title = String(root.querySelector('#edit-channel-title')?.value || '').trim(); const description = String(root.querySelector('#edit-channel-description')?.value || '').trim(); if (Array.from(title).length > 50) { if (errorEl) errorEl.textContent = 'Название: максимум 50 символов.'; return; } if (Array.from(description).length > 250) { if (errorEl) errorEl.textContent = 'Описание: максимум 250 символов.'; return; } saveBtn.disabled = true; if (errorEl) errorEl.textContent = ''; try { await onSave({ title, description, avatar: selectedAvatar }); root.innerHTML = ''; } catch (error) { saveBtn.disabled = false; if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось изменить канал.'); } }); } function bindSubmitOnPlainEnter(textarea, submit) { if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return; textarea.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; if (event.shiftKey || event.ctrlKey) return; event.preventDefault(); submit(); }); } function setActionTitle(button, label) { if (!button) return; button.title = label; button.setAttribute('aria-label', label); const labelEl = button.querySelector('.channel-action-label'); if (labelEl) labelEl.textContent = label; } async function copyTextToClipboard(text) { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.append(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return !!ok; } function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) { const source = raw && typeof raw === 'object' ? raw : {}; return { authorLogin, authorBlockchainName: messageRef?.blockchainName || source.authorBlockchainName || '', blockNumber: messageRef?.blockNumber ?? source?.messageRef?.blockNumber ?? '', blockHash: messageRef?.blockHash || source?.messageRef?.blockHash || '', localNumber, msgSubType: msgSubType ?? source.msgSubType ?? '', createdAtMs: timestampMs || source.createdAtMs || '', text: String(text || ''), signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера', publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера', raw, }; } function openBlockchainDetailsModal(details) { const root = document.getElementById('modal-root'); const rawText = JSON.stringify(details.raw || details, null, 2); root.innerHTML = ` `; root.querySelector('#blockchain-details-close')?.addEventListener('click', () => { root.innerHTML = ''; }); root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => { await copyTextToClipboard(rawText); showToast('Данные блокчейна скопированы'); }); root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => { const rawEl = root.querySelector('#blockchain-raw-block'); if (!rawEl) return; rawEl.hidden = !rawEl.hidden; }); } function renderDraftAttachments(container, attachments) { if (!container) return; container.innerHTML = ''; (Array.isArray(attachments) ? attachments : []).forEach((item, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'draft-attachment-chip'; button.textContent = `${item.name} · ${item.ar}`; button.title = 'Нажмите, чтобы убрать вложение'; button.addEventListener('click', () => { const ok = window.confirm('Отменить вложение?'); if (!ok) return; attachments.splice(index, 1); renderDraftAttachments(container, attachments); }); container.append(button); }); } function openReplyModal({ onSubmit, navigate, mode = 'reply' }) { const isRating = mode === 'rating'; const title = isRating ? 'Оценка' : 'Ответ'; const placeholder = isRating ? 'Текст оценки' : 'Текст ответа'; const emptyError = isRating ? 'Введите текст оценки или добавьте вложение.' : 'Введите текст ответа или добавьте вложение.'; const submitError = isRating ? 'Не удалось отправить оценку.' : 'Не удалось отправить ответ.'; const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#reply-text'); const attachmentsEl = root.querySelector('#reply-attachments'); const errorEl = root.querySelector('#reply-error'); const submitEl = root.querySelector('#reply-submit'); const attachments = []; let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; submitEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; root.querySelector('#reply-attach')?.toggleAttribute('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 && attachments.length === 0) { errorEl.textContent = emptyError; return; } setBusy(true); errorEl.textContent = ''; try { await onSubmit(composeMessageWithAttachments(text, attachments)); attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment)); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, submitError); } }); root.querySelector('#reply-attach')?.addEventListener('click', async () => { try { if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; return; } const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, gateway: state.entrySettings.arweaveServer, selectedTxIds: attachments.map((attachment) => attachment.ar), }); if (!item) return; attachments.push(item); renderDraftAttachments(attachmentsEl, attachments); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.'); } }); bindSubmitOnPlainEnter(textEl, () => submitEl?.click()); if (textEl) textEl.focus(); } function openStatusActionCommentModal({ title, submitLabel, onSubmit }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#channel-status-action-text'); const errorEl = root.querySelector('#channel-status-action-error'); const submitEl = root.querySelector('#channel-status-action-submit'); let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; if (textEl) textEl.disabled = inFlight; if (submitEl) { submitEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Сохраняем...' : (submitLabel || 'Сохранить'); } }; const close = () => { root.innerHTML = ''; }; root.querySelector('#channel-status-action-cancel')?.addEventListener('click', close); submitEl?.addEventListener('click', async () => { if (inFlight) return; setBusy(true); errorEl.textContent = ''; try { await onSubmit(String(textEl?.value || '').trim()); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.'); } }); bindSubmitOnPlainEnter(textEl, () => submitEl?.click()); if (textEl) textEl.focus(); } function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) { const root = document.getElementById('modal-root'); const rows = (Array.isArray(options) ? options : []) .map((item, index) => ` `) .join(''); root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#channel-status-menu-cancel')?.addEventListener('click', close); root.querySelectorAll('[data-status-index]').forEach((button) => { button.addEventListener('click', async (event) => { const idx = Number(event.currentTarget?.dataset?.statusIndex || -1); const option = options[idx]; if (!option) return; close(); await onSelect(option); }); }); } function sortPostsByTimeDesc(posts = []) { return [...posts].sort((a, b) => { const byTime = Number(b?.timestampMs || 0) - Number(a?.timestampMs || 0); if (byTime !== 0) return byTime; return Number(b?.messageRef?.blockNumber || 0) - Number(a?.messageRef?.blockNumber || 0); }); } function getEntrypointPosts(posts = []) { return sortPostsByTimeDesc((Array.isArray(posts) ? posts : []).filter((post) => isEntrypointSubType(post?.msgSubType))); } function flashAndScrollToMessage(messageRef) { const key = messageRefKey(messageRef); if (!key) return false; const cards = Array.from(document.querySelectorAll('.channel-message-card[data-message-key]')); const target = cards.find((card) => card.dataset.messageKey === key); if (!target) return false; target.classList.remove('is-focus-flash'); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); window.setTimeout(() => target.classList.add('is-focus-flash'), 60); window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800); return true; } function openEntrypointMenuModal({ onShowHistory }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; root.querySelector('#channel-entrypoint-close')?.addEventListener('click', () => { root.innerHTML = ''; }); root.querySelector('#channel-entrypoint-history')?.addEventListener('click', () => { root.innerHTML = ''; if (typeof onShowHistory === 'function') onShowHistory(); }); } function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect }) { const root = document.getElementById('modal-root'); const items = getEntrypointPosts(posts); const cleanTitle = String(channelTitle || '').trim() || 'канала'; root.innerHTML = ` `; const list = root.querySelector('#entrypoint-history-list'); if (list) { if (!items.length) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Оглавление в этом канале пока не добавлялось.'; list.append(empty); } else { items.forEach((post) => { const parsed = parseMessageAttachments(post.body); const item = document.createElement('button'); item.type = 'button'; item.className = 'entrypoint-history-item'; item.innerHTML = ` ${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')} #${escapeHtml(post.localNumber || '—')}

${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}

`; item.addEventListener('click', () => { root.innerHTML = ''; if (typeof onSelect === 'function') onSelect(post); }); list.append(item); }); } } root.querySelector('#entrypoint-history-close')?.addEventListener('click', () => { root.innerHTML = ''; }); } 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))) .filter((item) => !isStoriesChannel(item)) .map((item, index) => { const owner = String(item?.ownerLogin || '').trim(); const name = String(item?.channelName || '').trim(); const label = `${owner || 'my'}/${name || 'channel'}`; return ``; }) .join(''); root.innerHTML = ` `; const selectEl = root.querySelector('#repost-channel-select'); const textEl = root.querySelector('#repost-comment'); const errorEl = root.querySelector('#repost-error'); const submitEl = root.querySelector('#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('#repost-cancel')?.addEventListener('click', close); 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, 'Не удалось сделать репост.'); } }); bindSubmitOnPlainEnter(textEl, () => submitEl?.click()); 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 typeEl = root.querySelector('#channel-message-type'); const attachmentsEl = root.querySelector('#channel-message-attachments'); const errorEl = root.querySelector('#channel-message-error'); const submitEl = root.querySelector('#channel-message-submit'); const attachments = []; let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; submitEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; if (typeEl) typeEl.disabled = inFlight; root.querySelector('#channel-message-attach')?.toggleAttribute('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(); const msgSubType = Number(typeEl?.value || 10); if (!body && attachments.length === 0) { errorEl.textContent = 'Введите текст сообщения или добавьте вложение.'; return; } setBusy(true); errorEl.textContent = ''; try { await onSubmit({ text: composeMessageWithAttachments(body, attachments), msgSubType, }); attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment)); close(); } catch (error) { setBusy(false); errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.'); } }); root.querySelector('#channel-message-attach')?.addEventListener('click', async () => { try { if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; return; } const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, gateway: state.entrySettings.arweaveServer, selectedTxIds: attachments.map((attachment) => attachment.ar), }); if (!item) return; attachments.push(item); renderDraftAttachments(attachmentsEl, attachments); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.'); } }); bindSubmitOnPlainEnter(textEl, () => submitEl?.click()); 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('#message-history-list'); if (list) { rows.forEach((item, index) => { const row = document.createElement('div'); row.className = 'card stack'; const ts = toTimestampMs(item?.createdAtMs); const text = String(item?.text || '').trim() || 'удалено'; row.innerHTML = ` Версия ${index + 1}
${ts > 0 ? formatRelativeTime(ts) : '—'}

${text}

`; list.append(row); }); } root.querySelector('#message-history-close')?.addEventListener('click', () => { root.innerHTML = ''; }); } function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const textEl = root.querySelector('#edit-message-text'); const errorEl = root.querySelector('#edit-message-error'); if (textEl) textEl.value = String(initialText || ''); const close = () => { root.innerHTML = ''; }; root.querySelector('#edit-message-cancel')?.addEventListener('click', close); root.querySelector('#edit-message-save')?.addEventListener('click', async () => { const value = String(textEl?.value || '').trim(); if (!value && !allowEmptyText) { errorEl.textContent = 'Введите текст сообщения.'; return; } try { await onSave(value); close(); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.'); } }); root.querySelector('#edit-message-delete')?.addEventListener('click', async () => { try { await onDelete(); close(); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.'); } }); bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click()); 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 msgSubType = Number(message?.msgSubType || 0); const isStatusAction = isStatusActionSubType(msgSubType); const messageRef = hasRef ? { blockchainName: messageBch, blockNumber, blockHash, } : null; if (messageRef) { setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked'); } return { localNumber, authorLogin: message?.authorLogin || 'автор', body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')), versionsTotal: Number(message?.versionsTotal || 1), versions: Array.isArray(message?.versions) ? message.versions : [], likesCount: Number(message?.likesCount || 0), primaryLikesCount: Number(message?.primaryLikesCount || 0), shiningLikesCount: Number(message?.shiningLikesCount || 0), repliesCount: Number(message?.repliesCount || 0), ratingsCount: Number(message?.ratingsCount || 0), timestampMs: resolveMessageTimestampMs(message), messageRef, rawMessage: message, msgSubType, isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction, isStatusAction, targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber)) ? { blockchainName: String(message.targetBlockchainName).trim(), blockNumber: Number(message.targetBlockNumber), blockHash: normalizeMessageHash(message?.targetBlockHash), } : null, targetMsgSubType: Number(message?.targetMsgSubType || 0), targetText: String(message?.targetText || '').trim(), targetAuthorLogin: String(message?.targetAuthorLogin || '').trim(), targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(), targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0), reactionState: messageRef ? getMessageReactionState(messageRef) : '', isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), }; } async function loadFromApi(route, channelId) { const currentSessionLogin = String(state.session.login || '').trim(); const isAuthorized = !!currentSessionLogin; let unreadCount = 0; let messagesCount = 0; let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; if (!isAuthorized) { cachedFeed = {}; return cachedFeed; } cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 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 && isDiarySelector(selector)) { if (!isAuthorized) { throw new Error('Дневник доступен только после входа.'); } const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc'); const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : []; const posts = diaryMessages .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 })); return { channel: { name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME, displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(), displayName: 'Дневника', description: String(diaryPayload?.channel?.channelDescription || '').trim(), avaAr: '', avaSha256: '', avaSize: 0, metaUpdatedAtMs: 0, ownerName: currentSessionLogin, }, posts, metaEvents: [], reverseChannelMissingWarning: '', isOwnChannel: true, isSubscribed: true, isDiary: true, selector, }; } if (selector?.ownerBlockchainName && selector?.channelName) { let unreadCount = 0; let messagesCount = 0; const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); let channel = null; if (isAuthorized) { const allRows = await getAllRows(); 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) { const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : ''); if (ownerLoginForLookup) { try { const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 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('Канал не найден.'); } unreadCount = Number(channel?.unreadCount || 0); messagesCount = Number(channel?.messagesCount || 0); 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', currentSessionLogin); const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; if (!messagesCount) messagesCount = mergedMessages.length; const currentLogin = currentSessionLogin; const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const channelName = String(payload.channel?.channelName || '').trim(); const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1); if (channelTypeCode === CHANNEL_TYPE_STORIES) { throw new Error('Канал stories скрыт из пользовательского интерфейса.'); } 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', currentSessionLogin); 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() === currentSessionLogin.toLowerCase(); const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const isSubscribed = isAuthorized && 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 || 'неизвестный канал', ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(), displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(), displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`, description: String(payload.channel?.channelDescription || '').trim(), avaAr: String(payload.channel?.avaAr || '').trim(), avaSha256: String(payload.channel?.avaSha256 || '').trim(), avaSize: Number(payload.channel?.avaSize || 0), metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0), ownerName: ownerLogin || 'неизвестно', }, posts, metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [], reverseChannelMissingWarning, unreadCount, messagesCount, 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 scrollChannelToBottom(screen, smooth = true) { const feed = screen.querySelector('.channel-feed'); if (feed) { feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' }); } const appScreen = document.getElementById('app-screen'); if (appScreen) { appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); return; } window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); } function applyPendingScroll(screen, routeKey, forceBottom = false) { const target = pendingScrollByRoute.get(routeKey); if (!target && !forceBottom) return; const doScroll = () => { if (!target && forceBottom) { scrollChannelToBottom(screen, false); return; } if (target === '__LAST__') { scrollChannelToBottom(screen, true); 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 mapChannelMetaEvent(event, fallbackChannel) { return { kind: String(event?.kind || '').trim(), messageRef: event?.messageRef || null, timestampMs: Number(event?.createdAtMs || 0), title: String(event?.title || fallbackChannel?.displayTitle || fallbackChannel?.name || '').trim(), description: String(event?.description || '').trim(), avaAr: String(event?.avaAr || '').trim(), avaSha256: String(event?.avaSha256 || '').trim(), avaSize: Number(event?.avaSize || 0), ownerName: fallbackChannel?.ownerName || '', name: fallbackChannel?.name || '', }; } function renderChannelMetaEventCard(event) { const card = document.createElement('article'); card.className = 'card channel-system-event-card'; const label = event.kind === 'created' ? 'Создан канал' : 'Изменено описание канала'; card.innerHTML = ` ${escapeHtml(label)} `; card.addEventListener('click', () => { openChannelMetaDetailsModal({ title: label, channel: { name: event.name, displayTitle: event.title, description: event.description, avaAr: event.avaAr, avaSha256: event.avaSha256, avaSize: event.avaSize, ownerName: event.ownerName, }, changedAtMs: event.timestampMs, }); }); return card; } function renderPostCard(post, { navigate, selector, onToggleLike, onReply, onRating, onStatusAction, onOpenEntrypointMenu, onRepost, onShare, onEdit, }) { const versionsTotal = Number(post?.versionsTotal || 1); const card = document.createElement('article'); card.className = 'card stack channel-message-card'; if (post.isRating) card.classList.add('is-rating'); if (selector && isDiarySelector(selector)) card.classList.add('is-diary-entry'); const authorTile = document.createElement('button'); authorTile.type = 'button'; authorTile.className = 'channel-message-author-tile'; const avatar = createMessageAvatar(post.authorLogin); const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; const headRow = document.createElement('div'); headRow.className = 'channel-message-head-row'; const title = document.createElement('div'); title.className = 'channel-message-title author-line'; const titleMain = document.createElement('div'); titleMain.className = 'author-line-main'; 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}`; titleMain.append(loginEl, numberEl); title.append(titleMain); const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—'; 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: `История #${post.localNumber}`, versions: post.versions, }); }); title.append(editedMarker); } authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); headRow.append(authorTile); const typeMeta = getChannelMessageTypeMeta(post.msgSubType); if (typeMeta) { const typeButton = document.createElement('button'); typeButton.type = 'button'; typeButton.className = 'channel-message-type-button'; typeButton.textContent = typeMeta.label; if (typeMeta.actionable && typeof onStatusAction === 'function') { typeButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); onStatusAction(post); }); } else if (isEntrypointSubType(post.msgSubType) && typeof onOpenEntrypointMenu === 'function') { typeButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); onOpenEntrypointMenu(post); }); } else { typeButton.classList.add('is-static'); typeButton.disabled = true; } headRow.append(typeButton); } authorTile.addEventListener('click', (event) => { event.stopPropagation(); const cleanLogin = String(post.authorLogin || '').trim(); if (!cleanLogin) return; navigate(makeProfileRoute(cleanLogin)); }); const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено'; const parsedBody = parseMessageAttachments(post.body); if (isDeletedMessage) { card.classList.add('channel-message-card--deleted-compact'); const deleted = document.createElement('button'); deleted.type = 'button'; deleted.className = 'deleted-message-pill'; deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`; deleted.title = 'Открыть историю изменений'; deleted.addEventListener('click', (event) => { event.stopPropagation(); openMessageHistoryModal({ title: `История #${post.localNumber}`, versions: post.versions, }); }); card.append(deleted); return card; } else { card.append(headRow); if (parsedBody.attachments.length > 0) { card.append(createAttachmentCarouselElement(parsedBody.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: post.timestampMs, })); } if (post.isRating) { const ratingBadge = document.createElement('span'); ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating'; ratingBadge.textContent = 'Оценка'; card.append(ratingBadge); } if (post.isStatusAction && selector && isDiarySelector(selector)) { const statusBadge = document.createElement('span'); statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status'; statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label; card.append(statusBadge); if (post.targetText || post.targetAuthorLogin) { const targetPreview = document.createElement('div'); targetPreview.className = 'channel-message-target-preview'; const targetType = getChannelMessageTypeMeta(post.targetMsgSubType)?.label || 'Материал'; const targetParsed = parseDmTechBlocks(String(post.targetText || '').trim()); const targetText = String(targetParsed.displayText || targetParsed.visibleText || '').trim(); const targetAuthor = String(post.targetAuthorLogin || '').trim(); targetPreview.innerHTML = ` ${escapeHtml(targetType)} ${escapeHtml(targetAuthor || 'автор')}

${escapeHtml(targetText || 'Без текста')}

`; card.append(targetPreview); } } const body = document.createElement('p'); body.className = 'channel-message-body'; body.textContent = parsedBody.text; card.append(body); } const refKey = messageRefKey(post.messageRef); if (refKey) { card.dataset.messageKey = refKey; } if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) { card.dataset.localNumber = String(Number(post.localNumber)); } 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}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0} `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); 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; setActionTitle(likeButton, 'Лайк...'); 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} `; setActionTitle(replyButton, 'Ответить'); replyButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openReplyModal({ navigate, onSubmit: async (text) => onReply(post.messageRef, text), }); }); // Rating/opinion action is intentionally hidden from UI for now. // Backend/subtype/counters remain supported so the feature can be restored later. actions.append(likeButton, replyButton); const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'channel-action-item channel-action-share'; shareButton.innerHTML = ` Отправить `; setActionTitle(shareButton, 'Отправить'); shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); const route = buildThreadRoute(post.messageRef, selector); await onShare(route); }); actions.append(shareButton); if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { const originalBtn = document.createElement('button'); originalBtn.type = 'button'; originalBtn.className = 'channel-action-item'; originalBtn.innerHTML = ` Оригинал `; setActionTitle(originalBtn, 'Оригинал'); originalBtn.addEventListener('click', (event) => { event.stopPropagation(); const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName); if (!ownerLogin) return; const ok = window.confirm('Перейти к оригинальному сообщению?'); if (!ok) return; navigate(makeShineMessageRoute({ ownerLogin, messageBlockchainName: post.targetRef.blockchainName, messageBlockNumber: post.targetRef.blockNumber, })); }); actions.append(originalBtn); } const detailsButton = document.createElement('button'); detailsButton.type = 'button'; detailsButton.className = 'channel-action-item'; detailsButton.innerHTML = ` Данные блокчейна `; setActionTitle(detailsButton, 'Данные блокчейна'); detailsButton.addEventListener('click', (event) => { event.stopPropagation(); openBlockchainDetailsModal(buildBlockchainDetails({ messageRef: post.messageRef, authorLogin: post.authorLogin, timestampMs: post.timestampMs, text: post.body, raw: post.rawMessage, localNumber: post.localNumber, msgSubType: post.msgSubType, })); }); actions.append(detailsButton); if (post.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(post.body || '').trim() === 'удалено' ? '' : parsedBody.text, allowEmptyText: parsedBody.attachments.length > 0, onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }), onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }), }); }); actions.append(editButton); } card.append(actions); card.addEventListener('click', () => { const route = buildThreadRoute(post.messageRef, selector); if (route) navigate(route); }); return card; } function renderBody(screen, navigate, routeKey, channelData, handlers) { const unreadCount = Math.max(0, Number(channelData.unreadCount || 0)); const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0)); const readCount = Math.max(0, messagesCount - unreadCount); if (channelData.reverseChannelMissingWarning) { const reverseWarning = document.createElement('p'); reverseWarning.className = 'channel-head-meta'; reverseWarning.textContent = channelData.reverseChannelMissingWarning; screen.append(reverseWarning); } const actionButton = document.createElement('button'); actionButton.className = 'destructive-btn channel-main-action'; actionButton.textContent = 'Подписаться на канал'; const addMessageButton = document.createElement('button'); addMessageButton.type = 'button'; addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose'; addMessageButton.textContent = 'Добавить сообщение'; addMessageButton.addEventListener('click', (event) => { animatePress(event.currentTarget); handlers.onAddMessage(); }); const feed = document.createElement('div'); feed.className = 'stack channel-feed'; const postsByKey = new Map(); const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : []) .map((event) => mapChannelMetaEvent(event, channelData.channel)); let unreadLineInserted = unreadCount === 0; const feedItems = [ ...metaEvents.map((event) => ({ type: 'meta', timestampMs: event.timestampMs, blockNumber: Number(event?.messageRef?.blockNumber || 0), event, })), ...channelData.posts.map((post) => ({ type: 'post', timestampMs: post.timestampMs, blockNumber: Number(post?.messageRef?.blockNumber || 0), post, })), ].sort((a, b) => { const byTime = Number(a.timestampMs || 0) - Number(b.timestampMs || 0); if (byTime !== 0) return byTime; return Number(a.blockNumber || 0) - Number(b.blockNumber || 0); }); if (feedItems.length) { feedItems.forEach((item) => { if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) { const unreadLine = document.createElement('div'); unreadLine.className = 'card channel-unread-line'; unreadLine.textContent = 'Не прочитанные сообщения'; feed.append(unreadLine); unreadLineInserted = true; } if (item.type === 'meta') { feed.append(renderChannelMetaEventCard(item.event)); return; } const row = renderPostCard(item.post, { navigate, selector: channelData.selector, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onRating: handlers.onRating, onStatusAction: handlers.onStatusAction, onOpenEntrypointMenu: handlers.onOpenEntrypointMenu, onRepost: handlers.onRepost, onShare: handlers.onShare, onEdit: handlers.onEdit, }); const key = messageRefKey(item.post.messageRef); if (key) { postsByKey.set(key, item.post); } feed.append(row); }); } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = channelData.isDiary ? 'К сожалению, у вас пока еще ничего нет в Дневнике.' : 'Ждем ваших начинаний'; feed.append(empty); } 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.isDiary) { screen.append(feed, backButton); } else if (channelData.isOwnChannel) { screen.append(feed, addMessageButton); } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { screen.append(actionButton, feed, backButton); } else { screen.append(feed, backButton); } const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey); applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0); if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) { window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40); } const tracker = createChannelReadTracker({ screen, routeKey, settingKey: buildChannelSettingsKey( channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, channelData.channel?.name || channelData.channel?.channelName, ), unreadCount, messagesCount, initialSeenCount: readCount, }); return tracker.cleanup; } 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, chrome }) { 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 channelHeaderButton = document.createElement('button'); channelHeaderButton.type = 'button'; channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action'; channelHeaderButton.textContent = 'Канал: ...'; channelHeaderButton.disabled = true; const header = renderHeader({ centerNode: channelHeaderButton, leftAction: { label: '<', onClick: () => navigate('channels-list') }, rightActions: [ { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, ], }); const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn'); if (channelEntrypointButton) { channelEntrypointButton.disabled = true; channelEntrypointButton.hidden = true; } chrome?.setTopbar(header); const rerender = () => { const current = document.querySelector('section.channels-screen--channel'); if (!current) return; const next = render({ navigate, route }); current.cleanup?.(); current.replaceWith(next); }; let activeSelector = null; const requireSigningSession = () => { const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { state.authReturnHash = window.location.pathname || '/channels'; 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 onRating = async (messageRef, text) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockRating({ login, storagePwd, message: messageRef, text }); const scrollTarget = messageRefKey(messageRef); if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget); softHaptic(15); showToast('Оценка отправлена'); rerender(); }; const onStatusAction = async (post) => { const options = getStatusActionOptionsForTarget(post?.msgSubType); const typeMeta = getChannelMessageTypeMeta(post?.msgSubType); if (!options.length || !typeMeta || !post?.messageRef) return; openStatusActionMenuModal({ targetLabel: typeMeta.label, options, onSelect: async (option) => { openStatusActionCommentModal({ title: option.modalTitle, submitLabel: option.label, onSubmit: async (text) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockStatusAction({ login, storagePwd, message: post.messageRef, text, statusSubType: option.subType, }); softHaptic(14); showToast(`${option.label} сохранено`); rerender(); }, }); }, }); }; const loadOwnedChannelsForRepost = async (login) => { const feed = await authService.listSubscriptionsFeed(login, 1000); const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; return rows .map((row) => { const selector = { ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber), channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash), }; if (!selector.ownerBlockchainName || !Number.isFinite(selector.channelRootBlockNumber) || selector.channelRootBlockNumber < 0) { return null; } return { ownerLogin: String(row?.channel?.ownerLogin || '').trim(), channelName: String(row?.channel?.channelName || '').trim(), channelTypeCode: Number(row?.channel?.channelTypeCode ?? 1), selector, }; }) .filter(Boolean) .filter((item) => !isStoriesChannel(item)); }; const isSameChannelSelector = (a, b) => ( String(a?.ownerBlockchainName || '').trim() === String(b?.ownerBlockchainName || '').trim() && Number(a?.channelRootBlockNumber) === Number(b?.channelRootBlockNumber) && normalizeRouteHash(a?.channelRootBlockHash) === normalizeRouteHash(b?.channelRootBlockHash) ); const onRepost = async (messageRef) => { const { login, storagePwd } = requireSigningSession(); const channels = await loadOwnedChannelsForRepost(login); if (!channels.length) throw new Error('У вас пока нет каналов для репоста.'); openRepostModal({ navigate, channels, onSubmit: async ({ channel, text }) => { await authService.addBlockRepost({ login, storagePwd, channel, message: messageRef, text, }); if (isSameChannelSelector(channel, activeSelector)) { pendingScrollByRoute.set(routeKey, '__LAST__'); rerender(); } softHaptic(12); showToast('Репост опубликован'); }, }); }; 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, msgSubType = 10) => { const { login, storagePwd } = requireSigningSession(); if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockTextPost({ login, storagePwd, channel: activeSelector, text: bodyText, msgSubType, }); pendingScrollByRoute.set(routeKey, '__LAST__'); softHaptic(15); showToast('Сообщение отправлено'); rerender(); }; const onEditPost = async (messageRef, text) => { const { login, storagePwd } = requireSigningSession(); const isDiaryEdit = isDiarySelector(activeSelector); if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockEditMessage({ login, storagePwd, message: messageRef, text, isChannelPost: !isDiaryEdit, channel: isDiaryEdit ? null : activeSelector, }); softHaptic(12); showToast('Сообщение обновлено'); rerender(); }; const onEditChannelMeta = async ({ title, description, avatar }) => { const { login, storagePwd } = requireSigningSession(); if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockChannelMeta({ login, storagePwd, channel: activeSelector, title, description, avatar, }); if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar); softHaptic(12); showToast('Профиль канала обновлён'); rerender(); }; screen.append(statusBox); const skeleton = renderSkeleton(screen); let cleanupSeenTracking = null; (async () => { try { const apiData = await loadFromApi(route, channelId); activeSelector = apiData?.selector || null; const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel'; const entrypointPosts = getEntrypointPosts(apiData?.posts); const openEntrypointHistory = () => { openEntrypointHistoryModal({ channelTitle: titleLabel, posts: apiData?.posts, onSelect: (post) => { if (!flashAndScrollToMessage(post?.messageRef)) { showToast('Не удалось найти запись оглавления в ленте'); } }, }); }; if (channelHeaderButton) { channelHeaderButton.textContent = titleLabel; channelHeaderButton.disabled = false; channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '', }); if (aboutRoute) navigate(aboutRoute); }; } if (channelEntrypointButton) { const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0; channelEntrypointButton.hidden = !canShowEntrypointButton; channelEntrypointButton.disabled = !canShowEntrypointButton; channelEntrypointButton.onclick = () => { const latestEntrypoint = entrypointPosts[0]; if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) { showToast('Не удалось найти актуальное оглавление'); } }; } skeleton.remove(); cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { onAddMessage: () => { openAddMessageModal({ channelName: apiData?.channel?.name || '', navigate, onSubmit: async ({ text: bodyText, msgSubType }) => { try { await onAddPost(bodyText, msgSubType); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); } }, }); }, 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, 'Не удалось отправить ответ.')); } }, onRating: async (messageRef, text) => { try { await onRating(messageRef, text); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось отправить оценку.')); } }, onStatusAction: async (post) => { try { await onStatusAction(post); showStatus(''); } catch (error) { showStatus(toUserMessage(error, 'Не удалось записать действие.')); } }, onOpenEntrypointMenu: () => { openEntrypointMenuModal({ onShowHistory: openEntrypointHistory, }); }, onRepost: async (messageRef) => { try { await onRepost(messageRef); showStatus(''); } catch (error) { showStatus(toUserMessage(error, 'Не удалось сделать репост.')); } }, onAddPost: async (bodyText) => { try { await onAddPost(bodyText); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); } }, onShare: onShare, onEdit: async (messageRef, text) => { try { await onEditPost(messageRef, text); showStatus(''); } catch (error) { throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.')); } }, 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; }