Files
SHiNE-server/shine-UI/js/pages/channel-thread-view.js
T
2026-09-22 14:09:02 +03:00

1505 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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 {
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: 'Тред', 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';
});
}
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 '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
}
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);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
} 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 = `
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
<label class="field-label" for="thread-blockchain-details-text">Текст записи</label>
<textarea class="input" id="thread-blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
<pre class="blockchain-raw-block" id="thread-blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-blockchain-details-copy" type="button">Скопировать</button>
<button class="secondary-btn" id="thread-blockchain-details-raw" type="button">Показать сырой блок</button>
</div>
<button class="secondary-btn" id="thread-blockchain-details-close" type="button">Закрыть</button>
</div>
</div>
`;
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 `<option value="${index}">${label}</option>`;
})
.join('');
root.innerHTML = `
<div class="modal" id="thread-repost-modal">
<div class="modal-card stack">
<h3 class="modal-title">Репост</h3>
<label class="meta-muted" for="thread-repost-channel-select">Канал</label>
<select id="thread-repost-channel-select" class="input">${options}</select>
<label class="meta-muted" for="thread-repost-comment">Комментарий</label>
<textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
<div class="meta-muted inline-error" id="thread-repost-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-repost-submit" type="button">Опубликовать репост</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="thread-history-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<div class="stack" id="thread-history-list"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-history-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
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 = `
<strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
<p class="channel-message-body">${text}</p>
`;
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 ? new Date(node.createdAtMs).toLocaleString() : '—';
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 = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
`;
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 = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
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 = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
<span class="channel-action-label">Отправить</span>
`;
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 = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
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 = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
`;
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
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 = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
`;
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 = `
<span class="channel-action-icon" aria-hidden="true">✏️</span>
`;
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 (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) 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;
}
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;
}
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 = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
});
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: 'SHiNE · Тред',
text: 'Сообщение из треда SHiNE',
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);
threadHeaderButton.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) {
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
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();
let localSeq = 0;
const nextNumber = () => {
localSeq += 1;
return localSeq;
};
let ancestorsWrap = null;
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
ancestors.forEach((node, index) => {
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
});
}
let focusWrap = null;
if (focus) {
focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title';
focusTitle.textContent = 'Исходное сообщение';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
const composer = document.createElement('div');
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'primary-btn';
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
isActive: () => !disposed,
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
});
});
composer.append(reply);
chrome?.setComposer(composer);
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
}
if (ancestorsWrap) {
screen.append(ancestorsWrap);
const divider = document.createElement('div');
divider.className = 'thread-history-divider';
screen.append(divider);
}
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
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;
}