import { confirmDialog } from '../components/confirm-dialog.js'; import { iconHtml } from '../components/ui-icon.js'; import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js'; import { attachMessageMenu } from '../components/message-menu.js'; import { createTopBar } from '../components/topbar.js'; import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js'; import { captureClientError } from '../services/client-error-reporter.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { animatePress, createSkeletonCard, formatPostStamp, formatRelativeTime, longPressFeel, shareOrCopyLink, showToast, softHaptic, } from '../services/channels-ux.js'; import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { openChannelEditor } from '../components/channel-editor.js'; import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createEmojiPicker } from '../components/emoji-picker.js'; import { openShareSheet } from '../components/share-sheet.js'; import { composeMessageWithAttachments, createAttachmentCarouselElement, escapeHtml, parseMessageAttachments, } from '../services/attachment-format.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } }; const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_RATING = 30; 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 pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); const threadAvatarSnapshotCache = new Map(); const threadAvatarPendingByLogin = new Map(); async function loadThreadAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; const key = cleanLogin.toLowerCase(); if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key); if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key); const pending = loadProfileSnapshot(cleanLogin) .then((snapshot) => { threadAvatarSnapshotCache.set(key, snapshot || null); threadAvatarPendingByLogin.delete(key); return snapshot || null; }) .catch(() => { threadAvatarSnapshotCache.set(key, null); threadAvatarPendingByLogin.delete(key); return null; }); threadAvatarPendingByLogin.set(key, pending); return pending; } function createThreadAvatar(login) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', size: 'sm', className: 'channel-message-avatar avatar-plain', title, }); if (!cleanLogin) return avatarEl; void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, size: 'sm', className: 'channel-message-avatar avatar-plain', title, }); avatarEl.replaceWith(upgraded); }); return avatarEl; } function logThreadRuntimeError(stage, error, context = {}) { const message = String(error?.message || error || 'thread runtime error'); console.error(`[channel-thread-view:${stage}]`, error, context); captureClientError({ kind: 'channels_thread_runtime', message, stack: error?.stack || '', context: { stage, ...context }, }); } function encodeRoutePart(value = '') { return encodeURIComponent(String(value)); } function normalizeRouteHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); return normalized || '0'; } function normalizeMessageHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(normalized)) return ''; if (/^0+$/.test(normalized)) return ''; return normalized; } function toSafeInt(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function looksLikeBlockchainName(value) { const raw = String(value || '').trim(); return /^[^-]+-\d+$/.test(raw); } function makeReactionActionKey(messageRef) { const login = String(state.session.login || '').trim().toLowerCase(); const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockNumber = Number(messageRef?.blockNumber); const blockHash = normalizeMessageHash(messageRef?.blockHash); if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return ''; return `${login}|${blockchainName}|${blockNumber}|${blockHash}`; } function messageRefKey(messageRef) { const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockNumber = Number(messageRef?.blockNumber); const blockHash = normalizeMessageHash(messageRef?.blockHash); if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return ''; return `${blockchainName}:${blockNumber}:${blockHash}`; } function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); url.pathname = `/${cleanRoute}`; url.hash = ''; return url.toString(); } function parseThreadSelector(route) { const params = route?.params || {}; if (params.ownerBlockchainName && params.channelName && params.messageBlockNumber) { return { short: { ownerBlockchainName: String(params.ownerBlockchainName || '').trim(), channelName: String(params.channelName || '').trim(), }, message: { blockchainName: '', blockNumber: toSafeInt(params.messageBlockNumber), blockHash: normalizeRouteHash(params.messageBlockHash), }, channel: { ownerBlockchainName: '', channelRootBlockNumber: null, channelRootBlockHash: '0', }, }; } const blockNumber = toSafeInt(params.messageBlockNumber); if (!params.messageBlockchainName || blockNumber == null) return null; return { message: { blockchainName: String(params.messageBlockchainName), blockNumber, blockHash: normalizeRouteHash(params.messageBlockHash), }, channel: { ownerBlockchainName: String(params.channelOwnerBlockchainName || ''), channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber), channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash), }, }; } function allFeedSummaries() { const feed = state.channelsFeed || {}; return [ ...(feed.ownedChannels || []), ...(feed.followedUsersChannels || []), ...(feed.followedChannels || []), ].filter((summary) => { const typeCode = Number(summary?.channel?.channelTypeCode ?? 1); const channelName = String(summary?.channel?.channelName || '').trim().toLowerCase(); return typeCode !== 0 && channelName !== 'stories'; }); } const channelTitleByLabel = new Map(); function rememberChannelTitle(label, channel) { const title = String(channel?.displayName || channel?.displayTitle || '').trim(); if (label && title) channelTitleByLabel.set(label, title); } function resolveChannelDisplayName(channelSelector) { const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber; const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash; if (!channelSelector?.ownerBlockchainName || rootNumber == null) return ''; const ownerBch = String(channelSelector.ownerBlockchainName); const rootNo = Number(rootNumber); const rootHash = normalizeRouteHash(rootHashRaw); const found = allFeedSummaries().find((summary) => ( String(summary?.channel?.ownerBlockchainName || '') === ownerBch && Number(summary?.channel?.channelRoot?.blockNumber) === rootNo && normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash )); if (!found) return ''; const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; rememberChannelTitle(label, found.channel); return label; } function resolveChannelHeadingFromNode(node) { const info = node?.channelInfo; const ownerBch = String(info?.ownerBlockchainName || '').trim(); const rootBlockNumber = Number(info?.channelRoot?.blockNumber); if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return ''; if (rootBlockNumber === 0) return 'История пользователя'; const label = resolveChannelDisplayName({ ownerBlockchainName: ownerBch, channelRootBlockNumber: rootBlockNumber, channelRootBlockHash: '0', }); const slashIndex = label.indexOf('/'); const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim(); if (!channelName) return 'Сообщение в канале'; return `Сообщение в канале ${channelName}`; } 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 isEditableAsChannelPostSubType(msgSubType) { return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST || Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE || Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE || Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE; } function extractChannelContextFromThreadPayload(payload) { const focusInfo = payload?.focus?.channelInfo; if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { return { ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber), channelRootBlockHash: '0', }; } const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; for (let i = ancestors.length - 1; i >= 0; i -= 1) { const info = ancestors[i]?.channelInfo; if (info?.ownerBlockchainName && info?.channelRoot?.blockNumber != null) { return { ownerBlockchainName: String(info.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(info.channelRoot.blockNumber), channelRootBlockHash: '0', }; } } return null; } async function resolveChannelDisplayNameFromServer(channelSelector) { const ownerBch = String(channelSelector?.ownerBlockchainName || '').trim(); const rootNo = Number(channelSelector?.channelRootBlockNumber); if (!ownerBch || !Number.isFinite(rootNo) || rootNo < 0) return ''; const ownerLogin = extractLoginFromBlockchainName(ownerBch); if (!ownerLogin) return ''; try { const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000); const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const row = rows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch.toLowerCase() && Number(item?.channel?.channelRoot?.blockNumber) === rootNo )); if (!row?.channel?.channelName) return ''; channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash); const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`; rememberChannelTitle(label, row.channel); return label; } catch { return ''; } } function buildThreadRouteFromTarget(target, selector) { if (!target) return ''; const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); return makeShineMessageRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName), messageBlockchainName: target.blockchainName, messageBlockNumber: target.blockNumber, }); } function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') { const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { return makeShineChannelRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch), ownerBlockchainName: ownerBch, channelName: selector.short.channelName, }); } const label = String(resolvedChannelLabel || '').trim(); const slashIndex = label.indexOf('/'); const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : ''; return makeShineChannelRoute({ ownerLogin: extractLoginFromBlockchainName(ownerBch), ownerBlockchainName: ownerBch, channelName, }); } function resolveThreadBackRoute(selector, resolvedChannelLabel = '') { return buildChannelRouteFromThread(selector, resolvedChannelLabel) || 'channels-list'; } function resolveThreadPreviousInChannels(selector, resolvedChannelLabel = '') { const previousPath = String(getPreviousTrackedPath() || '').trim(); if (previousPath) { const previousRoute = parseRouteFromPath(previousPath); const previousPageId = String(previousRoute?.pageId || '').trim(); if ( previousPageId === 'channel-thread-view' || previousPageId === 'channel-view' ) { return previousPath; } } return resolveThreadBackRoute(selector, resolvedChannelLabel); } function buildTargetFromNode(node) { const blockchainName = String(node?.authorBlockchainName || '').trim(); const blockNumber = Number(node?.messageRef?.blockNumber); const blockHash = normalizeMessageHash(node?.messageRef?.blockHash); if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; return { blockchainName, blockNumber, blockHash }; } function buildRepostTargetFromNode(node) { const blockchainName = String(node?.targetBlockchainName || '').trim(); const blockNumber = Number(node?.targetBlockNumber); const blockHash = normalizeMessageHash(node?.targetBlockHash); if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; return { blockchainName, blockNumber, blockHash }; } function firstNonEmptyText(...candidates) { for (const candidate of candidates) { if (typeof candidate !== 'string') continue; const trimmed = candidate.trim(); if (trimmed.length > 0) return candidate; } return ''; } function latestVersionText(versions) { if (!Array.isArray(versions) || !versions.length) return ''; const version = versions[versions.length - 1]; if (typeof version?.text === 'string') return version.text; if (typeof version?.message === 'string') return version.message; if (typeof version?.body === 'string') return version.body; return ''; } function 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({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) { const source = raw && typeof raw === 'object' ? raw : {}; return { authorLogin, authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '', blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '', blockHash: target?.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, { isActive = () => true } = {}) { const root = document.getElementById('modal-root'); const rawText = JSON.stringify(details.raw || details, null, 2); root.innerHTML = ` `; root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => { root.innerHTML = ''; }); root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => { await copyTextToClipboard(rawText); if (!isActive()) return; showToast('Данные блокчейна скопированы'); }); root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => { const rawEl = root.querySelector('#thread-blockchain-raw-block'); if (!rawEl) return; rawEl.hidden = !rawEl.hidden; }); } function resolveNodeText(node) { return firstNonEmptyText( latestVersionText(node?.versions), node?.text, node?.message, node?.body, ); } function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'thread-reply' }) { const isRating = mode === 'rating'; return openChannelEditor({ id: 'thread-reply-modal', title: isRating ? 'Оценка' : 'Ответ', submitLabel: isRating ? 'Отправить' : 'Ответить', placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ', context, key: `${draftKey}:${mode}`, isActive, onSubmit: ({ text }) => onSubmit(text), }); } function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) { const root = document.getElementById('modal-root'); const options = (Array.isArray(channels) ? channels : []) .filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber))) .map((item, index) => { const owner = String(item?.ownerLogin || '').trim(); const name = String(item?.channelName || '').trim(); const label = `${owner || 'my'}/${name || 'channel'}`; return ``; }) .join(''); root.innerHTML = ` `; const selectEl = root.querySelector('#thread-repost-channel-select'); const textEl = root.querySelector('#thread-repost-comment'); const errorEl = root.querySelector('#thread-repost-error'); const submitEl = root.querySelector('#thread-repost-submit'); let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; if (selectEl) selectEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight; if (submitEl) { submitEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост'; } }; const close = () => { root.innerHTML = ''; }; root.querySelector('#thread-repost-cancel')?.addEventListener('click', close); 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 }); if (!isActive()) return; close(); } catch (error) { if (!isActive()) return; setBusy(false); 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('#thread-history-list'); if (list) { rows.forEach((item, index) => { const row = document.createElement('div'); row.className = 'card stack'; const ts = Number(item?.createdAtMs || 0); const text = String(item?.text || '').trim() || 'удалено'; row.innerHTML = ` Версия ${index + 1}
${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '–'}

${text}

`; list.append(row); }); } root.querySelector('#thread-history-close')?.addEventListener('click', () => { root.innerHTML = ''; }); } function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) { return openChannelEditor({ id: 'thread-edit-modal', title: 'Редактирование', submitLabel: 'Сохранить', placeholder: 'Текст сообщения', initialText, allowEmptyText, key: draftKey, allowAttachments: false, rawText: true, isActive, onSubmit: ({ text }) => onSave(text), }); } function renderNodeCard(node, heading, handlers, localNumber) { const card = document.createElement('article'); card.className = 'card stack thread-node-card channel-message-card'; const author = node?.authorLogin || 'автор'; const versions = Array.isArray(node?.versions) ? node.versions : []; const versionsTotal = Number(node?.versionsTotal || versions.length || 1); const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)'); const likes = Number(node?.likesCount || 0); const primaryLikes = Number(node?.primaryLikesCount || 0); const shiningLikes = Number(node?.shiningLikesCount || 0); const replies = Number(node?.repliesCount || 0); const ratings = Number(node?.ratingsCount || 0); const isOwnMessage = Boolean(state.session.isAuthorized && state.session.login) && String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase(); const msgSubType = Number(node?.msgSubType || 0); const isChannelPost = isEditableAsChannelPostSubType(msgSubType); const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING; const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null; const parsedText = parseMessageAttachments(text); if (isRating) card.classList.add('is-rating'); card.classList.add('is-counters-visible'); const headingText = String(heading || '').trim(); if (headingText) { const headingEl = document.createElement('strong'); headingEl.className = 'thread-node-heading'; headingEl.textContent = headingText; card.append(headingEl); } const authorTile = document.createElement('button'); authorTile.type = 'button'; authorTile.className = 'ui-button channel-message-author-tile'; const menuItems = []; const headRow = document.createElement('div'); headRow.className = 'channel-message-head-row'; const avatar = createThreadAvatar(author); const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; const title = document.createElement('div'); title.className = 'channel-message-title author-line'; const titleMain = document.createElement('div'); titleMain.className = 'author-line-main'; const loginEl = document.createElement('span'); loginEl.className = 'author-line-login'; loginEl.textContent = author; const numberEl = document.createElement('span'); numberEl.className = 'author-line-num'; numberEl.textContent = `· #${localNumber}`; titleMain.append(loginEl, numberEl); title.append(titleMain); if (versionsTotal > 1) { const editedMarker = document.createElement('span'); editedMarker.type = 'button'; editedMarker.className = 'ui-button message-edited-marker'; editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; editedMarker.title = 'Открыть историю редактирования'; editedMarker.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openMessageHistoryModal({ title: `История #${localNumber}`, versions, }); }); title.append(editedMarker); } const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '–'; if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU'); authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); headRow.append(authorTile); const typeMeta = getChannelMessageTypeMeta(node?.msgSubType); if (typeMeta) { const typeButton = document.createElement('button'); typeButton.type = 'button'; typeButton.className = 'ui-button channel-message-type-button'; typeButton.textContent = typeMeta.label; if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') { typeButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); handlers.onStatusAction(node); }); } else { typeButton.classList.add('is-static'); typeButton.disabled = true; } headRow.append(typeButton); } const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено'; if (isDeletedMessage) { card.classList.add('channel-message-card--deleted-compact'); const deleted = document.createElement('button'); deleted.type = 'button'; deleted.className = 'ui-button deleted-message-pill'; deleted.textContent = `Удалённое сообщение от ${author}`; deleted.title = 'Открыть историю изменений'; deleted.addEventListener('click', (event) => { event.stopPropagation(); openMessageHistoryModal({ title: `История #${localNumber}`, versions, }); }); card.append(deleted); return card; } else { card.append(headRow); if (parsedText.attachments.length > 0) { card.append(createAttachmentCarouselElement(parsedText.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs, })); } if (isRating) { const ratingBadge = document.createElement('span'); ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating'; ratingBadge.textContent = 'Оценка'; card.append(ratingBadge); } const body = document.createElement('p'); body.className = 'channel-message-body'; body.textContent = parsedText.text; card.append(body); } const target = buildTargetFromNode(node); const refKey = messageRefKey(target); if (!target || !handlers) return card; if (refKey) card.dataset.messageKey = refKey; setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked'); const actionKey = makeReactionActionKey(target); const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; const isLiked = getMessageReactionState(target) === 'liked'; const actions = document.createElement('div'); actions.className = 'thread-node-actions channel-message-actions'; const likeButton = document.createElement('button'); likeButton.type = 'button'; likeButton.className = 'ui-button channel-action-item thread-like-btn'; if (isLiked) likeButton.classList.add('is-liked'); likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} ${Number(likes) > 0 ? likes : ''} `; likeButton.setAttribute('aria-pressed', String(isLiked)); setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`); likeButton.disabled = isPending; likeButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); if (isPending) return; likeButton.disabled = true; setActionTitle(likeButton, 'Лайк...'); try { await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); } catch (error) { logThreadRuntimeError('like_click', error, { action: isLiked ? 'unlike' : 'like', targetBlockchainName: target?.blockchainName || '', targetBlockNumber: target?.blockNumber, }); handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like'); } finally { if (likeButton.isConnected) likeButton.disabled = false; } }); const replyButton = document.createElement('button'); replyButton.type = 'button'; replyButton.className = 'ui-button channel-action-item thread-reply-btn'; replyButton.innerHTML = ` Ответить ${Number(replies) > 0 ? replies : ''} `; setActionTitle(replyButton, 'Ответить'); replyButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openReplyModal({ onSubmit: async (textValue) => handlers.onReply(target, textValue), isActive: handlers.isActive, draftKey: `message:${refKey}`, context: { author, text: parsedText.text, attachmentLabel: parsedText.attachments[0]?.name || '', }, }); }); // Rating/opinion action is intentionally hidden from UI for now. // Backend/subtype/counters remain supported so the feature can be restored later. const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'ui-button channel-action-item thread-share-btn'; shareButton.innerHTML = ` Отправить `; setActionTitle(shareButton, 'Отправить'); shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); await handlers.onShare(target); }); // Репосты временно отключены до будущей реализации. // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md const discussionButton = document.createElement('button'); discussionButton.type = 'button'; discussionButton.className = 'ui-button channel-action-item'; discussionButton.innerHTML = `${iconHtml('message')}${Number(replies) > 0 ? `${replies}` : ''}`; discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`); discussionButton.addEventListener('click', () => handlers.onOpenThread(target)); actions.append(likeButton, discussionButton, shareButton, replyButton); if (repostTarget) { const originalButton = document.createElement('button'); originalButton.type = 'button'; originalButton.className = 'ui-button channel-action-item'; originalButton.innerHTML = ` Оригинал `; setActionTitle(originalButton, 'Оригинал'); originalButton.addEventListener('click', (event) => { event.stopPropagation(); const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName); if (!ownerLogin) return; handlers.navigate(makeShineMessageRoute({ ownerLogin, messageBlockchainName: repostTarget.blockchainName, messageBlockNumber: repostTarget.blockNumber, })); }); menuItems.push({ label: 'Оригинал', action: () => originalButton.click() }); } const detailsButton = document.createElement('button'); detailsButton.type = 'button'; detailsButton.className = 'ui-button channel-action-item'; detailsButton.innerHTML = ` Данные блокчейна `; setActionTitle(detailsButton, 'Данные блокчейна'); detailsButton.addEventListener('click', (event) => { event.stopPropagation(); openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text, raw: node, localNumber, msgSubType, }), { isActive: handlers.isActive }); }); menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() }); if (isOwnMessage) { const editButton = document.createElement('button'); editButton.type = 'button'; editButton.className = 'ui-button channel-action-item'; editButton.setAttribute('aria-label', 'Редактировать'); editButton.title = 'Редактировать'; editButton.innerHTML = ` `; editButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); openEditMessageModal({ isActive: handlers.isActive, draftKey: `edit:${messageRefKey(target)}`, initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text, allowEmptyText: parsedText.attachments.length > 0, onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }), onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }), }); }); menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() }); menuItems.push({ label: 'Удалить', danger: true, action: async () => { if (!await confirmDialog({ title: 'Удалить сообщение?', text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.', confirmLabel: 'Удалить', danger: true, })) return; try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); } catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); } } }); } if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: versions }) }); attachMessageMenu(card, headRow, menuItems); card.append(actions); authorTile.addEventListener('click', (event) => { event.stopPropagation(); const login = String(node?.authorLogin || '').trim(); if (!login) return; handlers.navigate(makeProfileRoute(login)); }); return card; } // ===== Обсуждение в раскладке V4: контекст записи, плоский список комментариев, строка ввода ===== // Дерево ответов → плоский список в порядке чтения; у каждого — на что он отвечает function flattenDescendants(items, parent = null, out = []) { (Array.isArray(items) ? items : []).forEach((branch) => { if (!branch?.node) return; out.push({ node: branch.node, parent }); flattenDescendants(branch.children, branch.node, out); }); return out; } function nodeKey(node) { return messageRefKey(buildTargetFromNode(node)); } // Исходная запись сверху: две строки, «Развернуть» — только если текст правда обрезан function renderThreadContext(node, { onOpenPost }) { const parsed = parseMessageAttachments(resolveNodeText(node)); const box = document.createElement('section'); box.className = 'thread-context'; const text = document.createElement('p'); text.className = 'thread-context__text'; text.textContent = parsed.text || (parsed.attachments.length ? 'Запись с вложением' : ''); const meta = document.createElement('div'); meta.className = 'thread-context__meta'; const time = document.createElement('span'); time.textContent = formatPostStamp(node?.createdAtMs); const expand = document.createElement('button'); expand.type = 'button'; expand.className = 'text-btn thread-context__expand'; expand.textContent = 'Развернуть'; expand.hidden = true; expand.addEventListener('click', () => { const open = box.classList.toggle('is-expanded'); expand.textContent = open ? 'Свернуть' : 'Развернуть'; }); const toPost = document.createElement('button'); toPost.type = 'button'; toPost.className = 'text-btn thread-context__open'; toPost.innerHTML = `${parsed.attachments.length ? 'Запись с фото' : 'К записи'}${iconHtml('open')}`; toPost.addEventListener('click', onOpenPost); meta.append(time, expand, toPost); box.append(text, meta); // обрезан ли текст — видно только после вставки в документ requestAnimationFrame(() => { expand.hidden = text.scrollHeight <= text.clientHeight + 1; }); return box; } function renderComment({ node, parent, focus, channelOwner, handlers }) { const target = buildTargetFromNode(node); const key = messageRefKey(target); const author = String(node?.authorLogin || 'автор'); const parsed = parseMessageAttachments(resolveNodeText(node)); const versions = Array.isArray(node?.versions) ? node.versions : []; const isOwn = Boolean(state.session.login) && author.toLowerCase() === String(state.session.login).toLowerCase(); const item = document.createElement('article'); item.className = 'thread-comment channel-message-card'; if (key) item.dataset.messageKey = key; const person = document.createElement('div'); person.className = 'thread-comment__person'; const who = document.createElement('button'); who.type = 'button'; who.className = 'thread-comment__who'; who.append(renderUserAvatar({ login: author, size: 'xs', className: 'thread-comment__avatar' })); const name = document.createElement('span'); name.className = 'thread-comment__name'; name.textContent = author; who.append(name); who.addEventListener('click', () => handlers.navigate(makeProfileRoute(author))); person.append(who); if (channelOwner && author.toLowerCase() === channelOwner.toLowerCase()) { const badge = document.createElement('span'); badge.className = 'thread-comment__badge'; badge.textContent = 'Автор'; person.append(badge); } const time = document.createElement('time'); time.textContent = formatPostStamp(node?.createdAtMs); if (node?.createdAtMs) time.title = new Date(node.createdAtMs).toLocaleString('ru-RU'); person.append(time); item.append(person); // Ответ на другой комментарий — цитата в две строки; нажатие ведёт к оригиналу if (parent && parent !== focus) { const quote = document.createElement('button'); quote.type = 'button'; quote.className = 'thread-quote'; const qa = document.createElement('strong'); qa.textContent = String(parent.authorLogin || ''); const qt = document.createElement('span'); qt.textContent = parseMessageAttachments(resolveNodeText(parent)).text || 'Вложение'; quote.append(qa, qt); quote.setAttribute('aria-label', `Ответ на комментарий ${parent.authorLogin || ''}. Перейти к нему`); quote.addEventListener('click', () => handlers.jumpTo(nodeKey(parent), key)); item.append(quote); } if (parsed.attachments.length) { item.append(createAttachmentCarouselElement(parsed.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs })); } const text = document.createElement('p'); text.className = 'thread-comment__text'; text.textContent = parsed.text; item.append(text); const actions = document.createElement('div'); actions.className = 'thread-comment__actions'; const reply = document.createElement('button'); reply.type = 'button'; reply.className = 'thread-comment__reply'; reply.textContent = 'Ответить'; reply.addEventListener('click', () => handlers.setReplyTo(node)); setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked'); const liked = getMessageReactionState(target) === 'liked'; const likes = Number(node?.likesCount || 0); const like = document.createElement('button'); like.type = 'button'; like.className = `channel-action-item thread-comment__like${liked ? ' is-liked' : ''}`; like.innerHTML = `${iconHtml('heart', liked)}${likes || ''}`; like.setAttribute('aria-pressed', String(liked)); like.setAttribute('aria-label', `${liked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`); like.addEventListener('click', async () => { like.disabled = true; try { await handlers.onToggleLike(target, liked ? 'unlike' : 'like'); } catch (error) { handlers.onActionError(error, liked ? 'unlike' : 'like'); } finally { if (like.isConnected) like.disabled = false; } }); const more = document.createElement('button'); more.type = 'button'; more.className = 'channel-action-item thread-comment__more'; more.innerHTML = iconHtml('more'); more.setAttribute('aria-label', `Действия с комментарием ${author}`); const menuItems = [ { label: 'Ответить', iconHtml: iconHtml('reply'), action: () => handlers.setReplyTo(node) }, { label: 'Поделиться комментарием', iconHtml: iconHtml('share'), action: () => handlers.shareComment(node) }, { label: 'Скопировать текст', iconHtml: iconHtml('copy'), action: async () => { try { await navigator.clipboard.writeText(parsed.text); showToast('Текст скопирован'); } catch { showToast('Не удалось скопировать', { kind: 'error' }); } } }, { label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text: resolveNodeText(node), raw: node, localNumber: 0, msgSubType: Number(node?.msgSubType || 0) }), { isActive: handlers.isActive }) }, ]; if (versions.length > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions }) }); if (isOwn) { menuItems.splice(1, 0, { label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => openEditMessageModal({ isActive: handlers.isActive, draftKey: `edit:${key}`, initialText: parsed.text, allowEmptyText: parsed.attachments.length > 0, onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsed.attachments), { isChannelPost: false }), onDelete: async () => handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }), }) }); menuItems.push({ divider: true }, { label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => { if (!await confirmDialog({ title: 'Удалить комментарий?', text: 'Он скроется из обсуждения. Прежние версии останутся в блокчейне.', confirmLabel: 'Удалить', danger: true })) return; try { await handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }); } catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить комментарий.')); } } }); } const menu = createDropdownMenu({ anchorEl: more, items: menuItems }); item.cleanup = () => menu.destroy(); actions.append(reply, like, more); item.append(actions); return item; } function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) { const wrap = document.createElement('div'); wrap.className = 'stack'; const normalized = Array.isArray(items) ? items : []; normalized.forEach((branch, index) => { try { const nodeNumber = nextNumber(); const row = renderNodeCard(branch?.node, '', handlers, nodeNumber); row.classList.add('thread-node-level'); if (parent) { const context = document.createElement('p'); context.className = 'thread-reply-context'; const excerpt = parseMessageAttachments(resolveNodeText(parent)).text; context.textContent = `В ответ ${parent.authorLogin || 'автору'} · ${excerpt.slice(0, 100) || 'Вложение'}`; row.prepend(context); } wrap.append(row); if (Array.isArray(branch?.children) && branch.children.length) { wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1, branch.node)); } } catch (error) { logThreadRuntimeError('render_descendants_branch', error, { depth, index }); } }); return wrap; } function applyPendingScroll(screen, routeKey, shouldContinue = () => true) { const target = pendingThreadScroll.get(routeKey); if (!target) return; const doScroll = () => { if (!shouldContinue()) return; if (target === '__LAST_REPLY__') { const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]'); const last = cards[cards.length - 1]; if (last) { last.scrollIntoView({ behavior: 'smooth', block: 'center' }); } pendingThreadScroll.delete(routeKey); return; } const node = screen.querySelector(`[data-message-key="${target}"]`); if (node) { node.scrollIntoView({ behavior: 'smooth', block: 'center' }); pendingThreadScroll.delete(routeKey); } }; return window.setTimeout(doScroll, 20); } function renderSkeleton(screen) { const wrap = document.createElement('div'); wrap.className = 'stack'; wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard()); screen.append(wrap); return wrap; } // «Не уведомлять об ответах» — пока только на этом устройстве (серверной настройки нет, см. docs/UI-Design) const THREAD_MUTE_KEY = 'shine-ui-thread-muted-v1'; function readMuted() { try { return JSON.parse(localStorage.getItem(THREAD_MUTE_KEY) || '{}') || {}; } catch { return {}; } } function isThreadMuted(key) { return Boolean(readMuted()[key]); } function setThreadMuted(key, muted) { const all = readMuted(); if (muted) all[key] = Date.now(); else delete all[key]; try { localStorage.setItem(THREAD_MUTE_KEY, JSON.stringify(all)); } catch { /* приватный режим */ } } // Черновик и адресат ответа принадлежат конкретному обсуждению const threadDrafts = new Map(); function createThreadComposer({ draftKey, isAuthorized, onLogin, onSend }) { const root = document.createElement('div'); root.className = 'channel-composer thread-composer'; if (!isAuthorized) { const login = document.createElement('button'); login.type = 'button'; login.className = 'primary-btn channel-main-action'; login.textContent = 'Войти и ответить'; login.addEventListener('click', onLogin); root.append(login); return { root, setReplyTo() {} }; } const draft = threadDrafts.get(draftKey) || { text: '', replyTo: null }; threadDrafts.set(draftKey, draft); const replying = document.createElement('div'); replying.className = 'thread-replying'; const row = document.createElement('div'); row.className = 'compose-row'; const field = document.createElement('div'); field.className = 'compose-field'; const input = document.createElement('textarea'); input.rows = 1; input.className = 'compose-field__input'; input.value = draft.text; const emojiBtn = document.createElement('button'); emojiBtn.type = 'button'; emojiBtn.className = 'compose-field__emoji'; emojiBtn.setAttribute('aria-label', 'Эмодзи'); emojiBtn.innerHTML = iconHtml('emoji'); field.append(input, emojiBtn); const send = document.createElement('button'); send.type = 'button'; send.className = 'compose-send'; send.setAttribute('aria-label', 'Отправить'); send.innerHTML = iconHtml('send'); row.append(field, send); const emojiSlot = document.createElement('div'); emojiSlot.className = 'thread-composer__emoji'; emojiSlot.hidden = true; root.append(replying, row, emojiSlot); const sync = () => { draft.text = input.value; send.disabled = !input.value.trim(); input.placeholder = draft.replyTo ? `Ответить ${draft.replyTo.authorLogin || ''}…` : 'Написать комментарий…'; replying.replaceChildren(); replying.hidden = !draft.replyTo; if (draft.replyTo) { const quote = document.createElement('div'); quote.className = 'thread-quote'; const a = document.createElement('strong'); a.textContent = String(draft.replyTo.authorLogin || ''); const t = document.createElement('span'); t.textContent = parseMessageAttachments(resolveNodeText(draft.replyTo)).text; quote.append(a, t); const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'thread-replying__cancel'; cancel.setAttribute('aria-label', 'Не отвечать на комментарий'); cancel.innerHTML = iconHtml('close'); cancel.addEventListener('click', () => { draft.replyTo = null; sync(); }); replying.append(quote, cancel); } }; input.addEventListener('input', sync); emojiBtn.addEventListener('click', () => { if (!emojiSlot.childElementCount) { emojiSlot.append(createEmojiPicker({ onSelect: (emoji) => { const at = input.selectionStart ?? input.value.length; input.value = `${input.value.slice(0, at)}${emoji}${input.value.slice(at)}`; sync(); } })); } emojiSlot.hidden = !emojiSlot.hidden; }); const submit = async () => { const text = input.value.trim(); if (!text) return; send.disabled = true; try { await onSend(text, draft.replyTo); draft.text = ''; draft.replyTo = null; input.value = ''; emojiSlot.hidden = true; } catch (error) { showToast(toUserMessage(error, 'Комментарий не отправлен. Текст сохранён.'), { kind: 'error' }); } finally { sync(); } }; send.addEventListener('click', submit); bindSubmitOnPlainEnter(input, submit); sync(); return { root, setReplyTo(node) { draft.replyTo = node; sync(); input.focus(); }, }; } export function render({ navigate, route, chrome }) { let selector = parseThreadSelector(route); const channelDisplayName = resolveChannelDisplayName(selector?.channel); const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`; let activeResolvedChannelLabel = channelDisplayName; const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--thread'; const positionKey = `${state.session.login}:thread:${routeKey}`; let disposed = false; let refreshSeq = 0; let refresh = () => {}; const refreshTimers = new Set(); const threadHeaderButton = document.createElement('button'); threadHeaderButton.type = 'button'; threadHeaderButton.className = 'topbar-identity channel-header-route-btn'; const threadHeaderText = document.createElement('span'); threadHeaderText.className = 'topbar-identity__text'; const threadHeaderTitle = document.createElement('span'); threadHeaderTitle.className = 'topbar-identity__title'; threadHeaderTitle.textContent = 'Обсуждение'; const threadHeaderChannel = document.createElement('span'); threadHeaderChannel.className = 'topbar-identity__sub'; threadHeaderChannel.textContent = '…'; threadHeaderText.append(threadHeaderTitle, threadHeaderChannel); threadHeaderButton.append(threadHeaderText); threadHeaderButton.disabled = true; // Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью let threadMenuActions = null; const header = createTopBar({ center: threadHeaderButton, className: 'topbar--identity', back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) }, actions: [{ label: '⋯', ariaLabel: 'Меню обсуждения', className: 'thread-menu-btn', menu: { minWidth: 240, items: () => threadMenuActions?.() || [] }, }], }); header.classList.add('channel-thread-topbar'); chrome?.setTopbar(header); const statusBox = document.createElement('div'); statusBox.className = 'card status-line is-unavailable channels-status'; statusBox.style.display = 'none'; const ensureActive = () => { if (disposed) throw new Error('Экран обсуждения уже закрыт.'); }; const showStatus = (message) => { if (disposed) return; if (!message) { statusBox.style.display = 'none'; statusBox.textContent = ''; return; } statusBox.textContent = message; statusBox.style.display = ''; }; const requireSigningSession = () => { const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); throw new Error('Для этого действия нужно войти'); } return { login, storagePwd }; }; const handlers = { navigate, isActive: () => !disposed, onToggleLike: async (target, action) => { const actionKey = makeReactionActionKey(target); if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.'); if (pendingReactionActions.has(actionKey)) return; const previousReaction = getMessageReactionState(target); const nextReaction = action === 'unlike' ? 'unliked' : 'liked'; pendingReactionActions.add(actionKey); try { const { login, storagePwd } = requireSigningSession(); if (action === 'unlike') { await authService.addBlockUnlike({ login, storagePwd, message: target }); } else { await authService.addBlockLike({ login, storagePwd, message: target }); } if (disposed) return; setMessageReactionState(target, nextReaction); softHaptic(10); void refresh(); } catch (error) { if (disposed) return; setMessageReactionState(target, previousReaction || 'unliked'); void refresh(); throw error; } finally { pendingReactionActions.delete(actionKey); } }, onReply: async (target, textValue) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockReply({ login, storagePwd, message: target, text: textValue }); ensureActive(); pendingThreadScroll.set(routeKey, '__LAST_REPLY__'); softHaptic(15); showToast('Ответ отправлен'); showStatus(''); void refresh(); }, onRating: async (target, textValue) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockRating({ login, storagePwd, message: target, text: textValue }); ensureActive(); pendingThreadScroll.set(routeKey, '__LAST_REPLY__'); softHaptic(15); showToast('Оценка отправлена'); showStatus(''); void refresh(); }, onRepost: async (target) => { const { login, storagePwd } = requireSigningSession(); const feed = await authService.listSubscriptionsFeed(login, 1000); if (disposed) return; const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []) .map((row) => { const selectorRow = { ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber), channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash), }; if (!selectorRow.ownerBlockchainName || !Number.isFinite(selectorRow.channelRootBlockNumber) || selectorRow.channelRootBlockNumber < 0) { return null; } return { ownerLogin: String(row?.channel?.ownerLogin || '').trim(), channelName: String(row?.channel?.channelName || '').trim(), channelTypeCode: Number(row?.channel?.channelTypeCode ?? 1), selector: selectorRow, }; }) .filter(Boolean) .filter((item) => Number(item.channelTypeCode) !== 0 && String(item.channelName || '').trim().toLowerCase() !== 'stories'); if (!channels.length) throw new Error('У вас пока нет каналов для репоста.'); openRepostModal({ navigate, channels, isActive: () => !disposed, onSubmit: async ({ channel, text }) => { await authService.addBlockRepost({ login, storagePwd, channel, message: target, text, }); ensureActive(); softHaptic(12); showToast('Репост опубликован'); showStatus(''); }, }); }, onShare: async (target) => { try { const routePath = buildThreadRouteFromTarget(target, selector); if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.'); const result = await shareOrCopyLink({ title: 'Сияние · Обсуждение', text: 'Сообщение из обсуждения в Сиянии', url: buildAbsoluteRouteUrl(routePath), }); if (disposed) return; if (result === 'copied') showToast('Ссылка скопирована'); if (result === 'shared') showToast('Ссылка передана'); if (result === 'copied' || result === 'shared') softHaptic(10); } catch (error) { showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.')); } }, onOpenThread: (target) => { const routePath = buildThreadRouteFromTarget(target, selector); if (!routePath) { showStatus('Не удалось определить ссылку на обсуждение.'); return; } navigate(routePath); }, onActionError: (error, action) => { const fallback = action === 'unlike' ? 'Не удалось убрать лайк.' : action === 'repost' ? 'Не удалось сделать репост.' : 'Не удалось поставить лайк.'; showStatus(toUserMessage(error, fallback)); }, onEdit: async (target, textValue, meta = {}) => { const { login, storagePwd } = requireSigningSession(); await authService.addBlockEditMessage({ login, storagePwd, message: target, text: textValue, isChannelPost: meta?.isChannelPost === true, channel: selector?.channel || null, }); ensureActive(); softHaptic(12); showToast('Сообщение обновлено'); showStatus(''); void refresh(); }, }; screen.append(statusBox); const clearContent = () => { chrome?.setComposer(null); screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.()); for (const timerId of refreshTimers) window.clearTimeout(timerId); refreshTimers.clear(); Array.from(screen.children).forEach((child) => { if (child !== statusBox) child.remove(); }); }; const clearOwnedModal = () => { const modalRoot = document.getElementById('modal-root'); if (!modalRoot) return; if (modalRoot.querySelector([ '#thread-blockchain-details-modal', '#thread-edit-modal', '#thread-history-modal', '#thread-reply-modal', '#thread-repost-modal', ].join(','))) { modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.()); modalRoot.innerHTML = ''; } }; const trackTimer = (timerId) => { if (timerId) refreshTimers.add(timerId); return timerId; }; refresh = async () => { if (disposed) return; const seq = ++refreshSeq; const hadContent = !!screen.querySelector('.thread-block'); const restorePosition = hadContent ? document.getElementById('app-screen')?.scrollTop : readChannelPosition(positionKey); if (!hadContent) clearContent(); showStatus(''); selector = parseThreadSelector(route); activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel); threadHeaderChannel.textContent = '…'; threadHeaderButton.disabled = true; threadHeaderButton.onclick = null; if (!selector) { const invalid = document.createElement('div'); invalid.className = 'card meta-muted'; invalid.textContent = 'Обсуждение не найдено: неверная ссылка.'; screen.append(invalid); return; } const skeleton = hadContent ? null : renderSkeleton(screen); try { let resolvedMessage = selector.message; if (selector.short?.ownerBlockchainName && selector.short?.channelName) { const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); if (disposed || seq !== refreshSeq) return; const allRows = [ ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), ]; const ownerRaw = String(selector.short.ownerBlockchainName || '').trim(); const ownerNormalized = ownerRaw.toLowerCase(); const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw); const channelNameNormalized = String(selector.short.channelName || '').trim().toLowerCase(); let channel = allRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); if (!channel) { channel = allRows.find((item) => ( String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } if (!channel && !looksLikeBlockchainName(ownerRaw)) { try { const ownerUser = await authService.getUser(ownerRaw); if (disposed || seq !== refreshSeq) return; const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); if (ownerBch) { channel = allRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } } catch { // ignore fallback lookup errors } } if (!channel && ownerLoginFromBch) { try { const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500); if (disposed || seq !== refreshSeq) return; const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; channel = ownerRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized )); } catch { // ignore owner feed lookup errors } } const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim(); const rootNo = Number(channel?.channel?.channelRoot?.blockNumber); const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash); if (!ownerBch || !Number.isFinite(rootNo) || !Number.isFinite(resolvedMessage?.blockNumber)) { throw new Error('Канал или сообщение не найдено.'); } selector.channel = { ownerBlockchainName: ownerBch, channelRootBlockNumber: rootNo, channelRootBlockHash: rootHash, }; resolvedMessage = { blockchainName: ownerBch, blockNumber: resolvedMessage.blockNumber, blockHash: normalizeMessageHash(resolvedMessage?.blockHash), }; } const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login); if (disposed || seq !== refreshSeq) return; skeleton?.remove(); const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; const focus = payload?.focus || null; const descendants = Array.isArray(payload?.descendants) ? payload.descendants : []; const focusHash = normalizeMessageHash(focus?.messageRef?.blockHash); if (focusHash && selector?.message) { selector.message.blockHash = focusHash; } if ((!selector?.channel?.ownerBlockchainName || selector?.channel?.channelRootBlockNumber == null) && payload) { const context = extractChannelContextFromThreadPayload(payload); if (context) { selector.channel = { ownerBlockchainName: context.ownerBlockchainName, channelRootBlockNumber: context.channelRootBlockNumber, channelRootBlockHash: normalizeRouteHash(context.channelRootBlockHash), }; } } let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel); if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) { resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel); if (disposed || seq !== refreshSeq) return; } activeResolvedChannelLabel = resolvedChannelLabel; const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно'; const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel; if (threadHeaderButton) { threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle; threadHeaderButton.title = 'Открыть канал'; threadHeaderButton.disabled = false; threadHeaderButton.onclick = (event) => { event.preventDefault(); animatePress(event.currentTarget); const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel); if (routeToChannel) navigate(routeToChannel); else navigate('channels-list'); }; } clearContent(); const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || ''); const openPost = () => { const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel); if (routeToChannel) navigate(routeToChannel); }; const focusTarget = focus ? buildTargetFromNode(focus) : null; threadMenuActions = () => { const muted = isThreadMuted(routeKey); return [ { label: 'Перейти к записи', iconHtml: iconHtml('doc'), action: openPost }, { label: muted ? 'Уведомлять об ответах' : 'Не уведомлять об ответах', iconHtml: iconHtml(muted ? 'bell' : 'bell-off'), action: () => { setThreadMuted(routeKey, !muted); showToast(muted ? 'Уведомления об ответах включены' : 'Ответы в этом обсуждении не будут приходить в уведомления'); } }, { label: 'Поделиться записью', iconHtml: iconHtml('share'), action: () => { if (!focusTarget) return; openShareSheet({ title: 'Поделиться записью', kicker: `${resolvedChannelTitle} · запись`, text: parseMessageAttachments(resolveNodeText(focus)).text, url: buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector)), }); } }, ]; }; if (ancestors.length) { const ancestorsWrap = document.createElement('div'); ancestorsWrap.className = 'thread-block thread-block--ancestors'; ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost }))); screen.append(ancestorsWrap); } if (focus) { const focusWrap = document.createElement('div'); focusWrap.className = 'thread-block thread-block--focus'; focusWrap.append(renderThreadContext(focus, { onOpenPost: openPost })); screen.append(focusWrap); } const comments = flattenDescendants(descendants); const listWrap = document.createElement('div'); listWrap.className = 'thread-block thread-block--replies'; const listTitle = document.createElement('h3'); listTitle.className = 'thread-section-title'; listTitle.textContent = `Комментарии · ${comments.length}`; listWrap.append(listTitle); // Возврат к ответу после перехода по цитате const returnBtn = document.createElement('button'); returnBtn.type = 'button'; returnBtn.className = 'thread-return'; returnBtn.hidden = true; returnBtn.textContent = 'Вернуться к ответу'; let returnKey = ''; const highlight = (el) => { screen.querySelectorAll('.thread-comment.is-highlight').forEach((x) => x.classList.remove('is-highlight')); el?.classList.add('is-highlight'); }; returnBtn.addEventListener('click', () => { const el = listWrap.querySelector(`[data-message-key="${CSS.escape(returnKey)}"]`); el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); highlight(el); returnBtn.hidden = true; }); const commentHandlers = { ...handlers, jumpTo: (key, fromKey) => { const el = listWrap.querySelector(`[data-message-key="${CSS.escape(key)}"]`); if (!el) return; el.scrollIntoView({ block: 'center', behavior: 'smooth' }); highlight(el); returnKey = fromKey; returnBtn.hidden = false; }, setReplyTo: (node) => composerApi?.setReplyTo(node), shareComment: (node) => openShareSheet({ title: 'Поделиться комментарием', kicker: `${node.authorLogin} · комментарий`, text: parseMessageAttachments(resolveNodeText(node)).text, url: `${buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector))}#comment-${node?.messageRef?.blockNumber ?? ''}`, }), }; if (comments.length) { comments.forEach(({ node, parent }) => { try { listWrap.append(renderComment({ node, parent, focus, channelOwner, handlers: commentHandlers })); } catch (error) { logThreadRuntimeError('render_comment', error); } }); } else { const empty = document.createElement('div'); empty.className = 'empty-note thread-empty'; empty.textContent = 'Пока нет комментариев. Можно начать разговор.'; listWrap.append(empty); } screen.append(listWrap, returnBtn); let composerApi = null; if (focus) { composerApi = createThreadComposer({ draftKey: routeKey, isAuthorized: state.session.isAuthorized, onLogin: () => { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); }, onSend: async (text, replyTo) => handlers.onReply(replyTo ? buildTargetFromNode(replyTo) : focusTarget, text), }); chrome?.setComposer(composerApi.root); } const focusWrap = screen.querySelector('.thread-block--focus'); trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq)); const hasPendingScroll = pendingThreadScroll.has(routeKey); if (!hasPendingScroll && Number.isFinite(restorePosition)) restoreChannelPosition(restorePosition); if (!hasPendingScroll && !Number.isFinite(restorePosition) && focusWrap) { trackTimer(window.setTimeout(() => { if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return; focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' }); }, 20)); } } catch (error) { if (disposed || seq !== refreshSeq) return; skeleton?.remove(); if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; } const failed = document.createElement('div'); failed.className = 'card meta-muted'; failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'primary-btn'; retry.textContent = 'Повторить'; retry.addEventListener('click', () => void refresh()); failed.append(retry); screen.append(failed); } }; screen.refresh = refresh; screen.cleanup = () => { if (disposed) return; rememberChannelPosition(positionKey); disposed = true; refreshSeq += 1; clearContent(); clearOwnedModal(); }; void refresh(); return screen; }