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

1485 lines
54 KiB
JavaScript
Raw Permalink 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,
setChannelsFeed,
setMessageReactionState,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
softHaptic,
} from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
} from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_PERSONAL = 100;
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const messageAvatarSnapshotCache = new Map();
const messageAvatarPendingByLogin = new Map();
async function loadMessageAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key);
if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
messageAvatarSnapshotCache.set(key, snapshot || null);
messageAvatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
messageAvatarSnapshotCache.set(key, null);
messageAvatarPendingByLogin.delete(key);
return null;
});
messageAvatarPendingByLogin.set(key, pending);
return pending;
}
function createMessageAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
className: 'channel-message-avatar',
title,
});
if (!cleanLogin) return avatarEl;
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'channel-message-avatar',
title,
});
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function isChannelsDemoMode() {
try {
const qs = new URLSearchParams(window.location.search);
if (qs.get('channelsDemo') === '1') return true;
return localStorage.getItem('shine-channels-demo') === '1';
} catch {
return false;
}
}
function encodeRoutePart(value = '') {
return encodeURIComponent(String(value));
}
function normalizeRouteHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
function normalizeMessageHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
if (/^0+$/.test(normalized)) return '';
return normalized;
}
function toSafeInt(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function looksLikeBlockchainName(value) {
const raw = String(value || '').trim();
return /^[^-]+-\d+$/.test(raw);
}
function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
}
function messageRefKey(messageRef) {
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${blockchainName}:${blockNumber}:${blockHash}`;
}
function parseMessageRefKey(key) {
const raw = String(key || '').trim();
if (!raw) return null;
const parts = raw.split(':');
if (parts.length !== 3) return null;
const blockNumber = Number(parts[1]);
const blockHash = normalizeMessageHash(parts[2]);
if (!parts[0] || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
return {
blockchainName: parts[0],
blockNumber,
blockHash,
};
}
function blockRefToMessageKey(blockRef, fallbackBch = '') {
const blockNumber = toSafeInt(blockRef?.blockNumber);
const blockHash = normalizeMessageHash(blockRef?.blockHash);
const blockchainName = String(fallbackBch || '').trim();
if (!blockchainName || blockNumber == null || !blockHash) return '';
return `${blockchainName}:${blockNumber}:${blockHash}`;
}
function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href);
url.pathname = `/${cleanRoute}`;
url.hash = '';
return url.toString();
}
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
if (params.ownerBlockchainName && params.channelName) {
return {
ownerBlockchainName: String(params.ownerBlockchainName || '').trim(),
channelName: String(params.channelName || '').trim(),
};
}
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
return {
ownerBlockchainName: String(params.ownerBlockchainName),
channelRootBlockNumber: rootBlockNumber,
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
};
}
}
const summary = channelId ? state.channelsIndex[channelId] : null;
if (!summary) return null;
return {
ownerBlockchainName: summary.channel?.ownerBlockchainName,
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
channelRootBlockHash: normalizeRouteHash(summary.channel?.channelRoot?.blockHash),
};
}
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
return makeShineMessageRoute({
ownerLogin,
messageBlockchainName: messageRef.blockchainName,
messageBlockNumber: messageRef.blockNumber,
});
}
function 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 resolveMessageText(message) {
return firstNonEmptyText(
message?.text,
message?.message,
message?.body,
latestVersionText(message?.versions),
);
}
function toTimestampMs(...candidates) {
for (const candidate of candidates) {
if (candidate == null) continue;
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
return candidate > 1e12 ? Math.round(candidate) : Math.round(candidate * 1000);
}
if (typeof candidate === 'string') {
const trimmed = candidate.trim();
if (!trimmed) continue;
const asNum = Number(trimmed);
if (Number.isFinite(asNum) && asNum > 0) {
return asNum > 1e12 ? Math.round(asNum) : Math.round(asNum * 1000);
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
}
return 0;
}
function resolveMessageTimestampMs(message) {
return toTimestampMs(
message?.messageTimeMs,
message?.message_time_ms,
message?.timeMs,
message?.time_ms,
message?.timestampMs,
message?.timestamp_ms,
message?.createdAtMs,
message?.created_at_ms,
message?.messageTime,
message?.createdAt,
message?.created_at,
message?.timestamp,
);
}
function openAboutChannelModal(channel) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
<div class="modal-card stack">
<h3 class="modal-title">О канале</h3>
<p><strong>${channel.displayName || channel.name}</strong></p>
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="reply-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#reply-text');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#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('#reply-cancel')?.addEventListener('click', close);
root.querySelector('#reply-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.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 openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
.map((item, index) => {
const owner = String(item?.ownerLogin || '').trim();
const name = String(item?.channelName || '').trim();
const label = `${owner || 'my'} / ${name || 'stories'}`;
return `<option value="${index}">${label}</option>`;
})
.join('');
root.innerHTML = `
<div class="modal" id="repost-modal">
<div class="modal-card stack">
<h3 class="modal-title">Репост</h3>
<label class="meta-muted" for="repost-channel-select">Канал</label>
<select id="repost-channel-select" class="input">${options}</select>
<label class="meta-muted" for="repost-comment">Комментарий</label>
<textarea id="repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="repost-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="repost-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="repost-cancel" type="button">Отмена</button>
<button class="primary-btn" id="repost-submit" type="button">Опубликовать репост</button>
</div>
</div>
</div>
`;
const selectEl = root.querySelector('#repost-channel-select');
const textEl = root.querySelector('#repost-comment');
const errorEl = root.querySelector('#repost-error');
const submitEl = root.querySelector('#repost-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
if (selectEl) selectEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
if (submitEl) {
submitEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост';
}
};
const close = () => { root.innerHTML = ''; };
root.querySelector('#repost-cancel')?.addEventListener('click', close);
root.querySelector('#repost-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const idx = Number(selectEl?.value ?? -1);
if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) {
errorEl.textContent = 'Выберите канал для репоста.';
return;
}
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите комментарий к репосту.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
});
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
<div class="modal-card stack">
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="channel-message-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-message-text');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-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('#channel-message-cancel')?.addEventListener('click', close);
root.querySelector('#channel-message-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
if (!body) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(body);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
});
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="message-history-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<div class="stack" id="message-history-list"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="message-history-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
const list = root.querySelector('#message-history-list');
if (list) {
rows.forEach((item, index) => {
const row = document.createElement('div');
row.className = 'card stack';
const ts = toTimestampMs(item?.createdAtMs);
const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = `
<strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
<p class="channel-message-body">${text}</p>
`;
list.append(row);
});
}
root.querySelector('#message-history-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
<div class="modal-card stack">
<h3 class="modal-title">Редактировать сообщение</h3>
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
<div class="meta-muted inline-error" id="edit-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
</div>
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
</div>
</div>
`;
const textEl = root.querySelector('#edit-message-text');
const errorEl = root.querySelector('#edit-message-error');
if (textEl) textEl.value = String(initialText || '');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
close();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
close();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
if (textEl) textEl.focus();
}
function mapApiMessageToPost(message, selector, localNumber) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim();
const hasRef = !!(messageBch && blockNumber != null && blockHash);
const resolvedText = resolveMessageText(message);
const messageRef = hasRef
? {
blockchainName: messageBch,
blockNumber,
blockHash,
}
: null;
if (messageRef) {
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
}
return {
localNumber,
authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
msgSubType: Number(message?.msgSubType || 0),
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
blockchainName: String(message.targetBlockchainName).trim(),
blockNumber: Number(message.targetBlockNumber),
blockHash: normalizeMessageHash(message?.targetBlockHash),
}
: null,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
};
}
async function loadFromApi(route, channelId) {
const currentSessionLogin = String(state.session.login || '').trim();
const isAuthorized = !!currentSessionLogin;
let cachedFeed = null;
const ensureFeed = async () => {
if (cachedFeed) return cachedFeed;
if (!isAuthorized) {
cachedFeed = {};
return cachedFeed;
}
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
return cachedFeed;
};
const getAllRows = async () => {
const feed = await ensureFeed();
return [
...(Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []),
...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []),
...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []),
];
};
let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerBlockchainName && selector?.channelName) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
let channel = null;
if (isAuthorized) {
const allRows = await getAllRows();
channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
if (!channel) {
channel = allRows.find((item) => (
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
}
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
try {
const ownerUser = await authService.getUser(routeOwnerRaw);
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
if (ownerBch) {
channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
}
} catch {
// ignore fallback lookup failures
}
}
}
if (!channel) {
const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : '');
if (ownerLoginForLookup) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
} catch {
// ignore owner feed lookup failures
}
}
}
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.');
}
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
channelName: selector.channelName,
};
}
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
const messages = Array.isArray(payload.messages) ? payload.messages : [];
let reverseChannelMissingWarning = '';
let mergedMessages = [...messages];
const currentLogin = currentSessionLogin;
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const channelName = String(payload.channel?.channelName || '').trim();
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
const canResolveReverse = (
channelTypeCode === CHANNEL_TYPE_PERSONAL
&& !!currentLogin
&& !!ownerLogin
&& !!channelName
&& ownerLogin.toLowerCase() === currentLogin.toLowerCase()
);
if (canResolveReverse) {
const allRows = await getAllRows();
const reverseSummary = allRows.find((item) => (
Number(item?.channel?.channelTypeCode ?? 1) === CHANNEL_TYPE_PERSONAL
&& String(item?.channel?.ownerLogin || '').trim().toLowerCase() === channelName.toLowerCase()
&& String(item?.channel?.channelName || '').trim().toLowerCase() === currentLogin.toLowerCase()
));
if (reverseSummary?.channel?.ownerBlockchainName && reverseSummary?.channel?.channelRoot?.blockNumber != null) {
const reverseSelector = {
ownerBlockchainName: String(reverseSummary.channel.ownerBlockchainName),
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
};
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
mergedMessages = mergedMessages.concat(reverseMessages);
} else {
reverseChannelMissingWarning = `У собеседника ${channelName} пока не создан ответный персональный чат.`;
}
}
const posts = mergedMessages
.map((message, index) => mapApiMessageToPost(message, selector, index + 1))
.sort((a, b) => {
const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0);
if (byTime !== 0) return byTime;
const aNum = Number(a?.messageRef?.blockNumber || 0);
const bNum = Number(b?.messageRef?.blockNumber || 0);
return aNum - bNum;
})
.map((post, index) => ({ ...post, localNumber: index + 1 }));
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
const isSubscribed = isAuthorized && followedRows.some((row) => (
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
));
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(),
ownerName: ownerLogin || 'неизвестно',
},
posts,
reverseChannelMissingWarning,
isOwnChannel,
isSubscribed,
selector,
};
}
function renderLoadError(screen, navigate, message, onRetry) {
const card = document.createElement('div');
card.className = 'card stack channels-status';
card.innerHTML = `
<strong>Не удалось загрузить канал</strong>
<p class="meta-muted">${message || 'Проверьте подключение к серверу и повторите попытку.'}</p>
`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', onRetry);
const back = document.createElement('button');
back.type = 'button';
back.className = 'secondary-btn';
back.textContent = 'Назад к каналам';
back.addEventListener('click', () => navigate('channels-list'));
card.append(retry, back);
screen.append(card);
}
function renderDemoFallback(screen, navigate, error) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные канала с сервера недоступны. Показан демо-контент.</p>
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
`;
screen.append(info);
const back = document.createElement('button');
back.className = 'secondary-btn';
back.textContent = 'Назад к каналам';
back.addEventListener('click', () => navigate('channels-list'));
screen.append(back);
}
function scrollChannelToBottom(screen, smooth = true) {
const feed = screen.querySelector('.channel-feed');
if (feed) {
feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
}
const appScreen = document.getElementById('app-screen');
if (appScreen) {
appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
return;
}
window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
function applyPendingScroll(screen, routeKey, forceBottom = false) {
const target = pendingScrollByRoute.get(routeKey);
if (!target && !forceBottom) return;
const doScroll = () => {
if (!target && forceBottom) {
scrollChannelToBottom(screen, false);
return;
}
if (target === '__LAST__') {
scrollChannelToBottom(screen, true);
pendingScrollByRoute.delete(routeKey);
return;
}
const element = screen.querySelector(`[data-message-key="${target}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
pendingScrollByRoute.delete(routeKey);
}
};
setTimeout(doScroll, 20);
}
function renderPostCard(post, {
navigate,
selector,
onToggleLike,
onReply,
onRepost,
onShare,
onEdit,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin);
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = post.authorLogin;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`;
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
title.append(loginEl, numberEl);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openMessageHistoryModal({
title: `История #${post.localNumber}`,
versions: post.versions,
});
});
title.append(editedMarker);
}
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim();
if (!cleanLogin) return;
navigate(makeProfileRoute(cleanLogin));
});
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
card.append(authorTile, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
card.dataset.messageKey = refKey;
}
card.classList.add('is-counters-visible');
if (!post.messageRef || !selector) return card;
const actions = document.createElement('div');
actions.className = 'channel-message-actions';
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</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;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
// Репосты временно отключены до будущей реализации.
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item channel-action-share';
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);
const route = buildThreadRoute(post.messageRef, selector);
await onShare(route);
});
actions.append(shareButton);
if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
originalBtn.className = 'channel-action-item';
originalBtn.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
`;
originalBtn.addEventListener('click', (event) => {
event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
if (!ownerLogin) return;
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
navigate(makeShineMessageRoute({
ownerLogin,
messageBlockchainName: post.targetRef.blockchainName,
messageBlockNumber: post.targetRef.blockNumber,
}));
});
actions.append(originalBtn);
}
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = '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({
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
actions.append(editButton);
}
card.append(actions);
card.addEventListener('click', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
return card;
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta';
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
screen.append(reverseWarning);
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
const postsByKey = new Map();
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
const row = renderPostCard(post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
});
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
}
feed.append(row);
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ждем ваших начинаний';
feed.append(empty);
}
if (!channelData.isSubscribed) {
actionButton.addEventListener('click', handlers.onSubscribeChannel);
}
const backButton = document.createElement('button');
backButton.className = 'secondary-btn channel-back-btn';
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) {
screen.append(feed);
} else if (!channelData.isSubscribed) {
screen.append(actionButton, feed, backButton);
} else {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
return () => {
// noop
};
}
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 channelId = route.params.channelId || '';
const routeSelector = buildSelectorFromRoute(route, channelId);
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const showStatus = (message) => {
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
return;
}
statusBox.textContent = message;
statusBox.style.display = '';
};
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
});
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
if (channelHeaderButton) {
channelHeaderButton.classList.add('channel-header-route-btn');
channelHeaderButton.disabled = true;
}
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
};
let activeSelector = null;
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
const onToggleLike = async (messageRef, action) => {
const actionKey = makeReactionActionKey(messageRef);
if (!actionKey) {
throw new Error('Некорректная ссылка на сообщение для реакции.');
}
if (pendingReactionActions.has(actionKey)) return;
const previousReaction = getMessageReactionState(messageRef);
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
pendingReactionActions.add(actionKey);
try {
const { login, storagePwd } = requireSigningSession();
if (action === 'unlike') {
await authService.addBlockUnlike({ login, storagePwd, message: messageRef });
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
rerender();
} catch (error) {
setMessageReactionState(messageRef, previousReaction || 'unliked');
rerender();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
}
};
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Ответ отправлен');
rerender();
};
const loadOwnedChannelsForRepost = async (login) => {
const feed = await authService.listSubscriptionsFeed(login, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
return rows
.map((row) => {
const selector = {
ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber),
channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash),
};
if (!selector.ownerBlockchainName || !Number.isFinite(selector.channelRootBlockNumber) || selector.channelRootBlockNumber < 0) {
return null;
}
return {
ownerLogin: String(row?.channel?.ownerLogin || '').trim(),
channelName: String(row?.channel?.channelName || '').trim(),
selector,
};
})
.filter(Boolean);
};
const isSameChannelSelector = (a, b) => (
String(a?.ownerBlockchainName || '').trim() === String(b?.ownerBlockchainName || '').trim()
&& Number(a?.channelRootBlockNumber) === Number(b?.channelRootBlockNumber)
&& normalizeRouteHash(a?.channelRootBlockHash) === normalizeRouteHash(b?.channelRootBlockHash)
);
const onRepost = async (messageRef) => {
const { login, storagePwd } = requireSigningSession();
const channels = await loadOwnedChannelsForRepost(login);
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
openRepostModal({
navigate,
channels,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
storagePwd,
channel,
message: messageRef,
text,
});
if (isSameChannelSelector(channel, activeSelector)) {
pendingScrollByRoute.set(routeKey, '__LAST__');
rerender();
}
softHaptic(12);
showToast('Репост опубликован');
},
});
};
const onShare = async (routePath) => {
try {
const routeToShare = String(routePath || '').trim();
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Каналы',
text: 'Тред из канала SHiNE',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
const onAddPost = async (bodyText) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockTextPost({
login,
storagePwd,
channel: activeSelector,
text: bodyText,
});
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
rerender();
};
const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockEditMessage({
login,
storagePwd,
message: messageRef,
text,
isChannelPost: true,
channel: activeSelector,
});
softHaptic(12);
showToast('Сообщение обновлено');
rerender();
};
screen.append(header);
screen.append(statusBox);
const skeleton = renderSkeleton(screen);
let cleanupSeenTracking = null;
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
if (channelHeaderButton) {
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel);
};
}
if (apiData?.isOwnChannel) {
const headerActions = header.querySelector('.header-actions');
if (headerActions) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'icon-btn channel-header-add-btn';
addBtn.textContent = 'Добавить сообщение';
addBtn.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
onSubmit: async (bodyText) => {
try {
await onAddPost(bodyText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
});
});
headerActions.append(addBtn);
}
}
skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => {
try {
await onToggleLike(messageRef, action);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
}
},
onReply: async (messageRef, text) => {
try {
await onReply(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
}
},
onRepost: async (messageRef) => {
try {
await onRepost(messageRef);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось сделать репост.'));
}
},
onAddPost: async (bodyText) => {
try {
await onAddPost(bodyText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
onShare: onShare,
onEdit: async (messageRef, text) => {
try {
await onEditPost(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
}
},
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: false,
});
const feed = await authService.listSubscriptionsFeed(login, 200);
setChannelsFeed(feed, state.channelsIndex);
softHaptic(15);
showToast('Подписка на канал выполнена');
rerender();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
},
});
} catch (error) {
skeleton.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error);
return;
}
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
return screen;
}