SHiNE-server/shine-UI/js/pages/channel-thread-view.js

875 lines
32 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 { renderHeader } from '../components/header.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 { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const pendingReactionActions = new Set();
const pendingThreadScroll = new Map();
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 extractLoginFromBlockchainName(value) {
const raw = String(value || '').trim();
const match = raw.match(/^(.+)-\d+$/);
if (!match) return '';
return String(match[1] || '').trim();
}
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 || []),
];
}
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 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 '';
return [
'm',
encodeRoutePart(target.blockchainName),
target.blockNumber,
].join('/');
}
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
return [
'channel',
encodeRoutePart(selector.short.ownerBlockchainName),
encodeRoutePart(selector.short.channelName),
].join('/');
}
const ownerBch = String(selector?.channel?.ownerBlockchainName || '').trim();
const label = String(resolvedChannelLabel || '').trim();
const slashIndex = label.indexOf('/');
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
if (!ownerBch || !channelName) return '';
return [
'channel',
encodeRoutePart(ownerBch),
encodeRoutePart(channelName),
].join('/');
}
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 firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (trimmed.length > 0) return candidate;
}
return '';
}
function latestVersionText(versions) {
if (!Array.isArray(versions)) return '';
for (let i = versions.length - 1; i >= 0; i -= 1) {
const version = versions[i];
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (value) return value;
}
return '';
}
function resolveNodeText(node) {
return firstNonEmptyText(
node?.text,
node?.message,
node?.body,
latestVersionText(node?.versions),
);
}
function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="thread-reply-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#thread-reply-text');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
root.querySelector('#thread-reply-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(text);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
}
});
if (textEl) textEl.focus();
}
function renderNodeCard(node, heading, handlers, localNumber) {
const card = document.createElement('article');
card.className = 'card stack thread-node-card channel-message-card';
card.classList.add('is-counters-visible');
const author = node?.authorLogin || 'автор';
const text = resolveNodeText(node) || '(пусто)';
const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 0);
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 = 'channel-message-author-tile';
const avatar = document.createElement('div');
avatar.className = 'channel-message-avatar';
avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A';
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = author;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${localNumber}`;
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
title.append(loginEl, numberEl);
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = text;
card.append(authorTile, 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 = 'channel-action-item thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
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');
}
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
`;
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
await handlers.onShare(target);
});
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
openThreadButton.className = 'channel-action-item thread-open-btn';
openThreadButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">#</span>
<span class="channel-action-label">Тред</span>
`;
openThreadButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
handlers.onOpenThread(target);
});
actions.append(likeButton, replyButton, openThreadButton, shareButton);
card.append(actions);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
const login = String(node?.authorLogin || '').trim();
if (!login) return;
handlers.navigate(`user/${encodeRoutePart(login)}`);
});
card.addEventListener('click', () => {
handlers.onOpenThread(target);
});
return card;
}
function renderDescendants(items, handlers, nextNumber, depth = 0) {
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, `Ответ ${index + 1}`, handlers, nodeNumber);
row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4)));
wrap.append(row);
if (Array.isArray(branch?.children) && branch.children.length) {
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
}
} catch (error) {
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
}
});
return wrap;
}
function applyPendingScroll(screen, routeKey) {
const target = pendingThreadScroll.get(routeKey);
if (!target) return;
const doScroll = () => {
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);
}
};
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 }) {
const selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
});
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
if (threadHeaderButton) {
threadHeaderButton.classList.add('channel-header-route-btn');
threadHeaderButton.disabled = true;
}
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const rerender = () => {
try {
const current = document.querySelector('section.channels-screen--thread');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
}
};
const showStatus = (message) => {
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-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
const handlers = {
navigate,
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 });
}
setMessageReactionState(target, nextReaction);
softHaptic(10);
rerender();
} catch (error) {
setMessageReactionState(target, previousReaction || 'unliked');
rerender();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
}
},
onReply: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Ответ отправлен');
showStatus('');
rerender();
},
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 (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'
? 'Не удалось убрать лайк.'
: 'Не удалось поставить лайк.';
showStatus(toUserMessage(error, fallback));
},
};
screen.append(header, statusBox);
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
return screen;
}
const skeleton = renderSkeleton(screen);
(async () => {
try {
let resolvedMessage = selector.message;
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
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);
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);
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);
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);
}
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');
};
}
let seq = 0;
const nextNumber = () => {
seq += 1;
return seq;
};
let ancestorsWrap = null;
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'История выше (на что это ответ)';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, 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(focusTitle);
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы';
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);
applyPendingScroll(screen, routeKey);
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
setTimeout(() => {
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 20);
}
} catch (error) {
skeleton.remove();
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
screen.append(failed);
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
}