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: 'small', 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: 'small', 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 = 72) { 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: 'small', 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 = `
${escapeHtml(details.authorBlockchainName)}
Номер записи${escapeHtml(details.blockNumber)}
Хэш${escapeHtml(details.blockHash)}
Тип${escapeHtml(details.msgSubType)}
Время${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}
Public key${escapeHtml(details.publicKey)}
Подпись${escapeHtml(details.signature)}
${escapeHtml(rawText)}
${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 = `${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} `; 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), }); }); const ratingButton = document.createElement('button'); ratingButton.type = 'button'; ratingButton.className = 'channel-action-item channel-action-rating'; ratingButton.innerHTML = ` Оценка ${post.ratingsCount || 0} `; setActionTitle(ratingButton, 'Оценка'); ratingButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openReplyModal({ navigate, mode: 'rating', onSubmit: async (text) => onRating(post.messageRef, text), }); }); // Репосты временно отключены до будущей реализации. // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md actions.append(likeButton, replyButton, ratingButton); 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; }