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

1290 lines
44 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 { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
softHaptic,
} from '../services/channels-ux.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
const seenFlushTimersByRoute = new Map();
const seenPendingByRoute = new Map();
const firstUnreadJumpByRoute = new Map();
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 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 getRevealedCounterSet(routeKey) {
const key = String(routeKey || '').trim();
if (!key) return new Set();
let bucket = revealedCountersByRoute.get(key);
if (!bucket) {
bucket = new Set();
revealedCountersByRoute.set(key, bucket);
}
return bucket;
}
function isCounterVisible(routeKey, counterKey) {
const key = String(counterKey || '').trim();
if (!key) return false;
return getRevealedCounterSet(routeKey).has(key);
}
function revealCounter(routeKey, counterKey) {
const key = String(counterKey || '').trim();
if (!key) return;
getRevealedCounterSet(routeKey).add(key);
}
function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href);
url.hash = `#/${cleanRoute}`;
return url.toString();
}
function channelDescriptionParamKey(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
if (!owner || !Number.isFinite(rootNo)) return '';
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function parseDescriptionOverride(payload) {
if (!payload || typeof payload !== 'object') {
return { hasOverride: false, description: '' };
}
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
return { hasOverride: false, description: '' };
}
if (!rawValue) {
return { hasOverride: true, description: '' };
}
try {
const parsed = JSON.parse(rawValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const value = typeof parsed.v === 'string' ? parsed.v : '';
return { hasOverride: true, description: value.trim() };
}
} catch {
// legacy raw string value
}
return { hasOverride: true, description: rawValue };
}
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
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 '';
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
encodeRoutePart(selector.ownerBlockchainName),
selector.channelRootBlockNumber,
normalizeRouteHash(selector.channelRootBlockHash),
].join('/');
}
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 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 }) {
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="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);
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 openAddMessageModal({ channelName, onSubmit }) {
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="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);
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 openEditDescriptionModal({ initialValue = '', onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-edit-description-modal">
<div class="modal-card stack">
<h3 class="modal-title">Описание канала</h3>
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<div class="meta-muted inline-error" id="channel-description-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-description-text');
const counterEl = root.querySelector('#channel-description-counter');
const errorEl = root.querySelector('#channel-description-error');
const submitEl = root.querySelector('#channel-description-submit');
const cancelEl = root.querySelector('#channel-description-cancel');
let inFlight = false;
const compute = () => {
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
const bytes = new TextEncoder().encode(value).length;
const ok = bytes <= 200;
return {
value,
bytes,
ok,
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
};
};
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
cancelEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
};
const close = () => {
root.innerHTML = '';
};
const updateValidation = () => {
const check = compute();
counterEl.textContent = `${check.bytes} / 200 байт`;
errorEl.textContent = check.error;
submitEl.disabled = inFlight || !check.ok;
return check;
};
cancelEl?.addEventListener('click', close);
textEl?.addEventListener('input', updateValidation);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(check.value);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
}
});
if (textEl) {
textEl.value = String(initialValue || '');
textEl.focus();
}
updateValidation();
}
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 || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
viewCount: Number(message?.viewCount || 0),
seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
};
}
async function loadFromApi(route, channelId) {
const selector = buildSelectorFromRoute(route, channelId);
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
const unreadFromPayload = Number(payload.unreadCount || 0);
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
const paramKey = channelDescriptionParamKey(selector);
if (!ownerLogin || !paramKey) return sourceDescription;
try {
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
const override = parseDescriptionOverride(paramPayload);
return override.hasOverride ? override.description : sourceDescription;
} catch {
return sourceDescription;
}
};
const resolvedDescription = await readDescription();
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: resolvedDescription,
ownerName: ownerLogin || 'неизвестно',
},
posts,
unreadCount: Number.isFinite(unreadFromPayload)
? Math.max(0, unreadFromPayload)
: posts.filter((post) => post.seenByMe !== true).length,
firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
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 applyPendingScroll(screen, routeKey) {
const target = pendingScrollByRoute.get(routeKey);
if (!target) return;
const doScroll = () => {
if (target === '__LAST__') {
const cards = screen.querySelectorAll('[data-message-key]');
const last = cards[cards.length - 1];
if (last) {
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
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,
routeKey,
selector,
onToggleLike,
onReply,
onShare,
}) {
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const topRow = document.createElement('div');
topRow.className = 'channel-message-top';
const avatar = document.createElement('div');
avatar.className = 'channel-message-avatar';
avatar.textContent = String(post.authorLogin || '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 = 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);
authorBlock.append(title, timestamp);
topRow.append(avatar, authorBlock);
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = post.body;
const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(topRow, body, views);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
card.dataset.messageKey = refKey;
}
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
if (!countersVisible) {
card.classList.remove('is-counters-visible');
} else {
card.classList.add('is-counters-visible');
}
const revealCounters = () => {
if (!refKey) return;
revealCounter(routeKey, refKey);
card.classList.add('is-counters-visible');
};
card.addEventListener('click', revealCounters);
if (!post.messageRef || !selector) return card;
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const actions = document.createElement('div');
actions.className = 'channel-message-actions';
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">✦</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) => {
animatePress(event.currentTarget);
if (isPending) return;
revealCounters();
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>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
openThreadButton.className = 'channel-action-item channel-action-thread';
openThreadButton.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>
`;
openThreadButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
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);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector);
await onShare(route);
});
actions.append(likeButton, replyButton, openThreadButton, shareButton);
card.append(actions);
return card;
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
const head = document.createElement('div');
head.className = 'card channel-head-card';
const title = document.createElement('strong');
title.className = 'channel-head-title';
title.textContent = String(channelData.channel.name || '').trim();
const owner = document.createElement('p');
owner.className = 'channel-head-meta';
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
const headActions = document.createElement('div');
headActions.className = 'channel-head-actions';
const aboutButton = document.createElement('button');
aboutButton.type = 'button';
aboutButton.className = 'secondary-btn small-btn';
aboutButton.textContent = 'О канале';
aboutButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(channelData.channel);
});
headActions.append(aboutButton);
if (channelData.isOwnChannel) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'secondary-btn small-btn';
editButton.textContent = '✎';
editButton.title = 'Редактировать описание';
editButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openEditDescriptionModal({
initialValue: channelData.channel.description || '',
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
});
});
headActions.append(editButton);
}
head.append(title);
head.append(owner, headActions);
const actionButton = document.createElement('button');
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
const unreadDivider = document.createElement('div');
unreadDivider.className = 'channels-unread-divider';
unreadDivider.textContent = 'Непрочитанные сообщения';
const unreadJump = document.createElement('button');
unreadJump.type = 'button';
unreadJump.className = 'channels-unread-jump';
unreadJump.innerHTML = `
<span class="channels-unread-jump-icon" aria-hidden="true">↓</span>
<span class="channels-unread-jump-badge"></span>
`;
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
const postsByKey = new Map();
const unreadKeys = new Set();
let seenFlushInFlight = false;
let seenObserver = null;
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
const row = renderPostCard(post, {
navigate,
routeKey,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onShare: handlers.onShare,
});
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ждем ваших начинаний';
feed.append(empty);
}
const syncUnreadState = () => {
unreadKeys.clear();
postsByKey.forEach((post, key) => {
if (post.seenByMe !== true) unreadKeys.add(key);
});
};
const updateUnreadJump = () => {
const unreadCount = unreadKeys.size;
unreadJump.classList.toggle('is-visible', unreadCount > 0);
unreadJump.hidden = unreadCount <= 0;
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
};
const mountUnreadDivider = () => {
unreadDivider.remove();
if (!unreadKeys.size) return;
const firstUnread = channelData.posts.find((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
if (!firstUnreadKey) return;
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
if (target) {
feed.insertBefore(unreadDivider, target);
}
};
const routePending = (() => {
let bucket = seenPendingByRoute.get(routeKey);
if (!bucket) {
bucket = new Set();
seenPendingByRoute.set(routeKey, bucket);
}
return bucket;
})();
const scheduleSeenFlush = () => {
const oldTimer = seenFlushTimersByRoute.get(routeKey);
if (oldTimer) clearTimeout(oldTimer);
const timer = setTimeout(async () => {
seenFlushTimersByRoute.delete(routeKey);
if (seenFlushInFlight) return;
const pendingKeys = [...routePending].filter((key) => {
const post = postsByKey.get(key);
return !!post && post.seenByMe !== true;
});
if (!pendingKeys.length) return;
const refs = pendingKeys
.map((key) => parseMessageRefKey(key))
.filter(Boolean);
if (!refs.length) return;
pendingKeys.forEach((key) => routePending.delete(key));
seenFlushInFlight = true;
try {
await handlers.onMarkSeenBatch(refs);
refs.forEach((ref) => {
const key = messageRefKey(ref);
const post = key ? postsByKey.get(key) : null;
if (post) post.seenByMe = true;
});
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
} catch (error) {
refs.forEach((ref) => {
const key = messageRefKey(ref);
if (!key) return;
const node = feed.querySelector(`[data-message-key="${key}"]`);
if (node) seenObserver?.observe(node);
});
handlers.onSeenError?.(error);
} finally {
seenFlushInFlight = false;
if (routePending.size) scheduleSeenFlush();
}
}, 220);
seenFlushTimersByRoute.set(routeKey, timer);
};
unreadJump.addEventListener('click', () => {
const unreadPosts = channelData.posts.filter((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
const key = messageRefKey(targetPost?.messageRef);
if (!key) return;
const target = feed.querySelector(`[data-message-key="${key}"]`);
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: channelData.channel.name,
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
});
});
} else {
actionButton.addEventListener('click', handlers.onUnfollowChannel);
}
const backButton = document.createElement('button');
backButton.className = 'secondary-btn channel-back-btn';
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton, unreadJump);
if (state.session.login && channelData.selector && channelData.posts.length) {
seenObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
const key = String(entry.target?.dataset?.messageKey || '').trim();
if (!key) return;
const post = postsByKey.get(key);
if (!post || post.seenByMe === true) return;
routePending.add(key);
seenObserver?.unobserve(entry.target);
});
if (routePending.size) scheduleSeenFlush();
}, {
root: document.getElementById('app-screen') || null,
threshold: [0.6],
});
feed.querySelectorAll('[data-message-key]').forEach((node) => {
const key = String(node.dataset.messageKey || '').trim();
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
});
}
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
const firstUnreadCandidate = channelData.firstUnreadKey
|| (() => {
const first = channelData.posts.find((post) => post.seenByMe !== true);
return messageRefKey(first?.messageRef);
})();
if (firstUnreadCandidate) {
const previous = firstUnreadJumpByRoute.get(routeKey);
if (previous !== firstUnreadCandidate) {
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
}
} else {
firstUnreadJumpByRoute.delete(routeKey);
}
applyPendingScroll(screen, routeKey);
return () => {
seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
};
}
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 rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
};
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
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 onMarkSeenBatch = async (refs) => {
if (!Array.isArray(refs) || !refs.length) return;
const login = String(state.session.login || '').trim();
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
await authService.markChannelMessagesSeen({
login,
channel: {
ownerBlockchainName: routeSelector.ownerBlockchainName,
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
channelRootBlockHash: routeSelector.channelRootBlockHash,
},
messages: refs,
});
};
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 (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockTextPost({
login,
storagePwd,
channel: routeSelector,
text: bodyText,
});
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
rerender();
};
const onEditDescription = async (descriptionText) => {
const { login, storagePwd } = requireSigningSession();
const selector = routeSelector;
const param = channelDescriptionParamKey(selector);
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
await authService.addBlockUserParam({
login,
storagePwd,
param,
value,
});
softHaptic(10);
showToast('Описание канала сохранено');
rerender();
};
screen.append(
renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
screen.append(statusBox);
const skeleton = renderSkeleton(screen);
let cleanupSeenTracking = null;
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
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, 'Не удалось отправить ответ.'));
}
},
onAddPost: async (bodyText) => {
try {
await onAddPost(bodyText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
onShare: onShare,
onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
});
softHaptic(15);
showToast('Отписка от канала выполнена');
navigate('channels-list');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
},
onMarkSeenBatch: async (refs) => {
try {
await onMarkSeenBatch(refs);
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
}
},
onSeenError: (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;
}