Files

3070 lines
121 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 { createTopBar } from '../components/topbar.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 { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
makeShineChannelAboutRoute,
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_REPOST = 50;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
const MSG_SUBTYPE_STATUS_LEARNED = 20;
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
const MSG_SUBTYPE_STATUS_STARTED = 120;
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_CHANNEL_DISPLAY_NAME = 'Дневник';
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: 'sm',
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: 'sm',
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 getChannelScrollRoot() {
return document.getElementById('app-screen');
}
function scrollRootBy(delta, smooth = false) {
const root = getChannelScrollRoot();
const behavior = smooth ? 'smooth' : 'auto';
if (root && typeof root.scrollBy === 'function') {
root.scrollBy({ top: delta, behavior });
return;
}
window.scrollBy({ top: delta, behavior });
}
function getUnreadAnchorViewportFraction(unreadCount = 0) {
const count = Math.max(0, Number(unreadCount || 0));
if (count <= 1) return 0.68;
if (count <= 3) return 0.56;
if (count <= 7) return 0.48;
return 0.42;
}
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
if (!element) return false;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
const rect = element.getBoundingClientRect();
const delta = rect.top - targetTop;
if (Math.abs(delta) < 2) return true;
scrollRootBy(delta, smooth);
return true;
}
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
return scrollElementToViewportFraction(
screen.querySelector('.channel-unread-line'),
getUnreadAnchorViewportFraction(unreadCount),
smooth,
);
}
function createChannelReadTracker({
screen,
routeKey,
ownerBlockchainName,
channelName,
initializeIfMissing = false,
unreadCount,
messagesCount,
initialSeenCount,
onPersistError = null,
onPersistSuccess = null,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim();
const cleanChannelName = String(channelName || '').trim();
const missingWriteParts = [];
if (!cleanOwnerBlockchainName) missingWriteParts.push('ownerBlockchainName');
if (!cleanChannelName) missingWriteParts.push('channelName');
if (!login) missingWriteParts.push('login');
if (!storagePwd) missingWriteParts.push('storagePwd');
const canWrite = missingWriteParts.length === 0;
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let initialPersistPending = !!initializeIfMissing;
let inFlight = false;
let disposed = false;
let rafId = 0;
let timerId = 0;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
};
const queueFlush = (delayMs = 180) => {
if (disposed || !canWrite) return;
clearTimer();
timerId = setTimeout(() => {
timerId = 0;
void flush();
}, Math.max(0, Number(delayMs) || 0));
};
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount && !initialPersistPending) return;
if (inFlight) {
queueFlush(120);
return;
}
inFlight = true;
try {
const persisted = await authService.setChannelReadState({
login,
ownerBlockchainName: cleanOwnerBlockchainName,
channelName: cleanChannelName,
readCount: next,
timeMs: Date.now(),
storagePwd,
});
const serverReadCount = Math.max(0, Number(persisted?.read_count ?? next));
const serverUnreadCount = Math.max(0, Number(persisted?.unread_count ?? Math.max(0, safeMessagesCount - serverReadCount)));
persistedSeenCount = Math.max(persistedSeenCount, Math.min(serverReadCount, safeMessagesCount));
desiredSeenCount = Math.max(desiredSeenCount, persistedSeenCount);
initialPersistPending = false;
if (typeof onPersistSuccess === 'function') {
onPersistSuccess({
readCount: persistedSeenCount,
unreadCount: serverUnreadCount,
applied: persisted?.applied !== false,
});
}
} catch (error) {
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
queueFlush(800);
} finally {
inFlight = false;
}
};
const collectSeenCount = () => {
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
if (!cards.length) return safeInitialSeenCount;
// Absence of the divider must never imply that unseen messages are read.
// Use only the highest message card that actually crossed the viewport.
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
const visibleBottom = Math.max(thresholdTop, viewportHeight - 24);
let seen = safeInitialSeenCount;
for (const card of cards) {
const localNumber = Number(card.dataset.localNumber || 0);
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
const rect = card.getBoundingClientRect();
if (rect.top > visibleBottom) break;
if (rect.bottom < 0) {
seen = Math.max(seen, localNumber);
continue;
}
seen = Math.max(seen, localNumber);
}
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
};
const measure = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = collectSeenCount();
if (next > desiredSeenCount) {
desiredSeenCount = next;
queueFlush(180);
}
});
};
if (!canWrite && (initializeIfMissing || unreadCount > 0)) {
const error = new Error(`Нельзя сохранить прочитанность канала: отсутствует ${missingWriteParts.join(', ')}`);
console.error('[SHiNE][ChannelReadState]', error);
if (typeof onPersistError === 'function') onPersistError(error, { retrying: false });
}
const scrollRoot = getChannelScrollRoot();
const onScroll = () => measure();
const onResize = () => measure();
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
} else {
window.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
// Opening a channel must NOT mark the whole channel as read.
// Only cards actually crossed by the viewport tracker advance desiredSeenCount.
// Exception: if the server has no row yet, persist the current client baseline once.
if (initialPersistPending) queueFlush(80);
window.setTimeout(() => measure(), 120);
const cleanup = () => {
disposed = true;
clearTimer();
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
scrollRoot.removeEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
};
return {
cleanup,
measure,
};
}
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 isStoriesChannel(channel = null) {
const typeCode = Number(channel?.channelTypeCode ?? channel?.channel?.channelTypeCode ?? 1);
const name = String(channel?.channelName || channel?.channel?.channelName || '').trim().toLowerCase();
return typeCode === CHANNEL_TYPE_STORIES || name === 'stories';
}
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 getChannelMessageTypeMeta(msgSubType) {
switch (Number(msgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE:
return { label: 'Упражнение', actionable: true };
case MSG_SUBTYPE_TEXT_SERVICE:
return { label: 'Услуга', actionable: true };
case MSG_SUBTYPE_TEXT_COURSE:
return { label: 'Курс', actionable: true };
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
return { label: 'Оглавление' };
default:
return null;
}
}
function isEntrypointSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT;
}
function isDiarySelector(selector) {
return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME;
}
function isStatusActionSubType(msgSubType) {
return new Set([
MSG_SUBTYPE_STATUS_DONE_ONCE,
MSG_SUBTYPE_STATUS_LEARNED,
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
MSG_SUBTYPE_STATUS_CONFIRMED,
MSG_SUBTYPE_STATUS_INTERESTED,
MSG_SUBTYPE_STATUS_STARTED,
MSG_SUBTYPE_STATUS_IN_STUDY,
MSG_SUBTYPE_STATUS_ABANDONED,
MSG_SUBTYPE_STATUS_COMPLETED,
]).has(Number(msgSubType || 0));
}
function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) {
const status = Number(statusSubType || 0);
const target = Number(targetMsgSubType || 0);
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' };
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' };
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' };
if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' };
if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' };
if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' };
if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' };
if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' };
if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' };
return { label: 'Действие' };
}
function getStatusActionOptionsForTarget(targetMsgSubType) {
switch (Number(targetMsgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE:
return [
{ subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' },
{ subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' },
];
case MSG_SUBTYPE_TEXT_SERVICE:
return [
{ subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' },
];
case MSG_SUBTYPE_TEXT_COURSE:
return [
{ subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' },
{ subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' },
{ subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' },
{ subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' },
{ subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' },
];
default:
return [];
}
}
function createChannelAvatarElement(channel, size = 96) {
const txId = String(channel?.avaAr || '').trim();
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
const wrap = renderAvatar({
initials: title.slice(0, 1).toUpperCase() || 'К',
avatar: txId ? { ar: txId } : null,
size: 'xl',
className: 'channel-profile-avatar',
title,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
return wrap;
}
function openChannelMetaDetailsModal({
title = 'Данные канала',
channel = {},
canEdit = false,
onEdit = null,
changedAtMs = 0,
} = {}) {
const avatarState = String(channel.avaAr || '').trim()
? 'Установлен'
: 'Не установлен';
const changedAtLabel = changedAtMs
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
: '—';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
<div class="modal-card stack channel-profile-card">
<div class="channel-profile-modal-head">
<h3 class="modal-title">${escapeHtml(title)}</h3>
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
</div>
<div id="about-channel-avatar-slot"></div>
<div class="channel-meta-details-grid">
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')}</strong>
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
</div>
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 96));
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#about-channel-edit')?.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onEdit === 'function') onEdit();
});
}
function openAboutChannelModal(channel, options = {}) {
openChannelMetaDetailsModal({
title: 'О канале',
channel,
canEdit: options.canEdit === true,
onEdit: options.onEdit,
changedAtMs: Number(channel?.metaUpdatedAtMs || 0),
});
}
function openEditChannelModal({ channel, onSave }) {
const root = document.getElementById('modal-root');
let selectedAvatar = channel?.avaAr ? {
name: 'Аватар',
ar: channel.avaAr,
sha256: channel.avaSha256,
size: channel.avaSize,
purpose: 'avatar',
} : null;
const renderAvatarPreview = () => {
const slot = root.querySelector('#edit-channel-avatar-preview');
if (!slot) return;
slot.innerHTML = '';
slot.append(createChannelAvatarElement({
...channel,
avaAr: selectedAvatar?.ar || '',
displayTitle: root.querySelector('#edit-channel-title')?.value || channel.displayTitle,
}, 86));
};
root.innerHTML = `
<div class="modal" id="edit-channel-modal">
<div class="modal-card stack channel-profile-card">
<h3 class="modal-title">Изменение канала</h3>
<div id="edit-channel-avatar-preview"></div>
<button class="secondary-btn" id="edit-channel-avatar" type="button">Выбрать аватар</button>
<label class="meta-muted" for="edit-channel-title">Человекочитаемое имя</label>
<input class="input" id="edit-channel-title" type="text" maxlength="50" value="${escapeHtml(channel.displayTitle || channel.name || '')}" />
<label class="meta-muted" for="edit-channel-description">Описание</label>
<textarea class="input" id="edit-channel-description" rows="4" maxlength="250">${escapeHtml(channel.description || '')}</textarea>
<p class="meta-muted inline-error" id="edit-channel-error"></p>
<div class="form-actions-grid">
<button class="secondary-btn" id="edit-channel-cancel" type="button">Отмена</button>
<button class="primary-btn" id="edit-channel-save" type="button">Сохранить</button>
</div>
</div>
</div>
`;
renderAvatarPreview();
root.querySelector('#edit-channel-cancel')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#edit-channel-title')?.addEventListener('input', renderAvatarPreview);
root.querySelector('#edit-channel-avatar')?.addEventListener('click', async () => {
try {
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (!item) return;
selectedAvatar = item;
renderAvatarPreview();
} catch (error) {
const errorEl = root.querySelector('#edit-channel-error');
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
}
});
root.querySelector('#edit-channel-save')?.addEventListener('click', async () => {
const saveBtn = root.querySelector('#edit-channel-save');
const errorEl = root.querySelector('#edit-channel-error');
const title = String(root.querySelector('#edit-channel-title')?.value || '').trim();
const description = String(root.querySelector('#edit-channel-description')?.value || '').trim();
if (Array.from(title).length > 50) {
if (errorEl) errorEl.textContent = 'Название: максимум 50 символов.';
return;
}
if (Array.from(description).length > 250) {
if (errorEl) errorEl.textContent = 'Описание: максимум 250 символов.';
return;
}
saveBtn.disabled = true;
if (errorEl) errorEl.textContent = '';
try {
await onSave({ title, description, avatar: selectedAvatar });
root.innerHTML = '';
} catch (error) {
saveBtn.disabled = false;
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось изменить канал.');
}
});
}
function bindSubmitOnPlainEnter(textarea, submit) {
if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return;
textarea.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.ctrlKey) return;
event.preventDefault();
submit();
});
}
function setActionTitle(button, label) {
if (!button) return;
button.title = label;
button.setAttribute('aria-label', label);
const labelEl = button.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = label;
}
async function copyTextToClipboard(text) {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
authorLogin,
authorBlockchainName: messageRef?.blockchainName || source.authorBlockchainName || '',
blockNumber: messageRef?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
blockHash: messageRef?.blockHash || source?.messageRef?.blockHash || '',
localNumber,
msgSubType: msgSubType ?? source.msgSubType ?? '',
createdAtMs: timestampMs || source.createdAtMs || '',
text: String(text || ''),
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
raw,
};
}
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
<div class="modal" id="blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
<label class="field-label" for="blockchain-details-text">Текст записи</label>
<textarea class="input" id="blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
<pre class="blockchain-raw-block" id="blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
<div class="form-actions-grid">
<button class="secondary-btn" id="blockchain-details-copy" type="button">Скопировать</button>
<button class="secondary-btn" id="blockchain-details-raw" type="button">Показать сырой блок</button>
</div>
<button class="secondary-btn" id="blockchain-details-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#blockchain-details-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
const rawEl = root.querySelector('#blockchain-raw-block');
if (!rawEl) return;
rawEl.hidden = !rawEl.hidden;
});
}
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
const emptyError = isRating
? 'Введите текст оценки или добавьте вложение.'
: 'Введите текст ответа или добавьте вложение.';
const submitError = isRating
? 'Не удалось отправить оценку.'
: 'Не удалось отправить ответ.';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">${title}</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<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 attachmentsEl = root.querySelector('#reply-attachments');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#reply-attach')?.toggleAttribute('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 && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
});
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title || 'Новое действие')}</h3>
<p class="meta-muted">Если хотите, можете добавить комментарий</p>
<textarea id="channel-status-action-text" class="input" rows="5" maxlength="2000" placeholder="Комментарий"></textarea>
<div class="meta-muted inline-error" id="channel-status-action-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-action-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-status-action-submit" type="button">${escapeHtml(submitLabel || 'Сохранить')}</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-status-action-text');
const errorEl = root.querySelector('#channel-status-action-error');
const submitEl = root.querySelector('#channel-status-action-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
if (textEl) textEl.disabled = inFlight;
if (submitEl) {
submitEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : (submitLabel || 'Сохранить');
}
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-status-action-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(String(textEl?.value || '').trim());
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActive = () => true }) {
const root = document.getElementById('modal-root');
const rows = (Array.isArray(options) ? options : [])
.map((item, index) => `
<button class="channel-menu-item channel-status-action-item" data-status-index="${index}" type="button">
${escapeHtml(item.label || 'Действие')}
</button>
`)
.join('');
root.innerHTML = `
<div class="modal" id="channel-status-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(targetLabel || 'Действия')}</h3>
<div class="stack">${rows}</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-menu-cancel" type="button">Закрыть</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-status-menu-cancel')?.addEventListener('click', close);
root.querySelectorAll('[data-status-index]').forEach((button) => {
button.addEventListener('click', async (event) => {
const idx = Number(event.currentTarget?.dataset?.statusIndex || -1);
const option = options[idx];
if (!option) return;
close();
if (!isActive()) return;
await onSelect(option);
});
});
}
function sortPostsByTimeDesc(posts = []) {
return [...posts].sort((a, b) => {
const byTime = Number(b?.timestampMs || 0) - Number(a?.timestampMs || 0);
if (byTime !== 0) return byTime;
return Number(b?.messageRef?.blockNumber || 0) - Number(a?.messageRef?.blockNumber || 0);
});
}
function getEntrypointPosts(posts = []) {
return sortPostsByTimeDesc((Array.isArray(posts) ? posts : []).filter((post) => isEntrypointSubType(post?.msgSubType)));
}
function flashAndScrollToMessage(messageRef) {
const key = messageRefKey(messageRef);
if (!key) return false;
const cards = Array.from(document.querySelectorAll('.channel-message-card[data-message-key]'));
const target = cards.find((card) => card.dataset.messageKey === key);
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
if (target.isConnected) target.classList.add('is-focus-flash');
}, 60);
window.setTimeout(() => {
if (target.isConnected) target.classList.remove('is-focus-flash');
}, 1800);
return true;
}
function openEntrypointMenuModal({ onShowHistory }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-entrypoint-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">Оглавление канала</h3>
<div class="stack">
<button class="channel-menu-item channel-status-action-item" id="channel-entrypoint-history" type="button">Просмотреть историю изменений оглавления</button>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-entrypoint-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('#channel-entrypoint-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#channel-entrypoint-history')?.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onShowHistory === 'function') onShowHistory();
});
}
function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect }) {
const root = document.getElementById('modal-root');
const items = getEntrypointPosts(posts);
const cleanTitle = String(channelTitle || '').trim() || 'канала';
root.innerHTML = `
<div class="modal" id="channel-entrypoint-history-modal">
<div class="modal-card stack">
<h3 class="modal-title">История оглавления</h3>
<p class="meta-muted">Показаны все версии оглавления канала ${escapeHtml(cleanTitle)}</p>
<div class="entrypoint-history-list" id="entrypoint-history-list"></div>
<button class="secondary-btn" id="entrypoint-history-close" type="button">Закрыть</button>
</div>
</div>
`;
const list = root.querySelector('#entrypoint-history-list');
if (list) {
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty);
} else {
items.forEach((post) => {
const parsed = parseMessageAttachments(post.body);
const item = document.createElement('button');
item.type = 'button';
item.className = 'ui-button entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
`;
item.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onSelect === 'function') onSelect(post);
});
list.append(item);
});
}
}
root.querySelector('#entrypoint-history-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
.filter((item) => !isStoriesChannel(item))
.map((item, index) => {
const owner = String(item?.ownerLogin || '').trim();
const name = String(item?.channelName || '').trim();
const label = `${owner || 'my'}/${name || 'channel'}`;
return `<option value="${index}">${label}</option>`;
})
.join('');
root.innerHTML = `
<div class="modal" id="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="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);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const idx = Number(selectEl?.value ?? -1);
if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) {
errorEl.textContent = 'Выберите канал для репоста.';
return;
}
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите комментарий к репосту.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
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="draft-attachments" id="channel-message-attachments"></div>
<div class="channel-message-tools">
<select id="channel-message-type" class="input channel-message-type-select">
<option value="${10}">Пост</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
</select>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</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 typeEl = root.querySelector('#channel-message-type');
const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
if (typeEl) typeEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('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();
const msgSubType = Number(typeEl?.value || 10);
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
});
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
const root = document.getElementById('modal-root');
const rows = Array.isArray(versions) ? versions : [];
root.innerHTML = `
<div class="modal" id="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 = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
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 && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
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 msgSubType = Number(message?.msgSubType || 0);
const isStatusAction = isStatusActionSubType(msgSubType);
const messageRef = hasRef
? {
blockchainName: messageBch,
blockNumber,
blockHash,
}
: null;
if (messageRef) {
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
}
return {
localNumber,
authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')),
versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0),
primaryLikesCount: Number(message?.primaryLikesCount || 0),
shiningLikesCount: Number(message?.shiningLikesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
rawMessage: message,
msgSubType,
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction,
isStatusAction,
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
blockchainName: String(message.targetBlockchainName).trim(),
blockNumber: Number(message.targetBlockNumber),
blockHash: normalizeMessageHash(message?.targetBlockHash),
}
: null,
targetMsgSubType: Number(message?.targetMsgSubType || 0),
targetText: String(message?.targetText || '').trim(),
targetAuthorLogin: String(message?.targetAuthorLogin || '').trim(),
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
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 unreadCount = 0;
let messagesCount = 0;
let readCount = 0;
let readStateInitialized = false;
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 && isDiarySelector(selector)) {
if (!isAuthorized) {
throw new Error('Дневник доступен только после входа.');
}
const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc');
const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
const posts = diaryMessages
.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 }));
return {
channel: {
name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME,
displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(),
displayName: 'Дневника',
description: String(diaryPayload?.channel?.channelDescription || '').trim(),
avaAr: '',
avaSha256: '',
avaSize: 0,
metaUpdatedAtMs: 0,
ownerName: currentSessionLogin,
},
posts,
metaEvents: [],
reverseChannelMissingWarning: '',
isOwnChannel: true,
isSubscribed: true,
isDiary: true,
selector,
};
}
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('Канал не найден.');
}
unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || 0);
readCount = Number(channel?.readCount || 0);
readStateInitialized = !!channel?.readStateInitialized;
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, null, 'asc', currentSessionLogin);
const messages = Array.isArray(payload.messages) ? payload.messages : [];
let reverseChannelMissingWarning = '';
let mergedMessages = [...messages];
if (!messagesCount) messagesCount = mergedMessages.length;
const currentLogin = currentSessionLogin;
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const channelName = String(payload.channel?.channelName || '').trim();
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
if (channelTypeCode === CHANNEL_TYPE_STORIES) {
throw new Error('Канал stories скрыт из пользовательского интерфейса.');
}
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, null, '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 || 'неизвестный канал',
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(),
avaAr: String(payload.channel?.avaAr || '').trim(),
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
avaSize: Number(payload.channel?.avaSize || 0),
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
ownerName: ownerLogin || 'неизвестно',
},
posts,
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
reverseChannelMissingWarning,
unreadCount,
messagesCount,
readCount,
isOwnChannel,
isSubscribed,
readStateInitialized,
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);
}
};
return window.setTimeout(doScroll, 20);
}
function mapChannelMetaEvent(event, fallbackChannel) {
return {
kind: String(event?.kind || '').trim(),
messageRef: event?.messageRef || null,
timestampMs: Number(event?.createdAtMs || 0),
title: String(event?.title || fallbackChannel?.displayTitle || fallbackChannel?.name || '').trim(),
description: String(event?.description || '').trim(),
avaAr: String(event?.avaAr || '').trim(),
avaSha256: String(event?.avaSha256 || '').trim(),
avaSize: Number(event?.avaSize || 0),
ownerName: fallbackChannel?.ownerName || '',
name: fallbackChannel?.name || '',
};
}
function renderChannelMetaEventCard(event) {
const card = document.createElement('article');
card.className = 'card channel-system-event-card';
const label = event.kind === 'created'
? 'Создан канал'
: 'Изменено описание канала';
card.innerHTML = `
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
`;
card.addEventListener('click', () => {
openChannelMetaDetailsModal({
title: label,
channel: {
name: event.name,
displayTitle: event.title,
description: event.description,
avaAr: event.avaAr,
avaSha256: event.avaSha256,
avaSize: event.avaSize,
ownerName: event.ownerName,
},
changedAtMs: event.timestampMs,
});
});
return card;
}
function likeCategoryCounts(post) {
const total = Math.max(0, Number(post?.likesCount || 0));
const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0)));
return {
shining,
official,
all: total,
total,
};
}
function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining' }) {
const overlay = document.createElement('div');
overlay.className = 'channel-likes-modal-overlay';
overlay.innerHTML = `
<section class="channel-likes-modal" role="dialog" aria-modal="true" aria-label="Кто поставил лайк">
<header class="channel-likes-modal__header">
<h3>Лайки</h3>
<button type="button" class="ui-button channel-likes-modal__close" aria-label="Закрыть">×</button>
</header>
<div class="channel-likes-tabs" role="tablist">
<button type="button" class="ui-button" data-like-tab="shining">Сияющие <span data-like-tab-count="shining"></span></button>
<button type="button" class="ui-button" data-like-tab="official">Официальные <span data-like-tab-count="official"></span></button>
<button type="button" class="ui-button" data-like-tab="all">Все <span data-like-tab-count="all"></span></button>
</div>
<div class="channel-likes-modal__status">Загрузка...</div>
<div class="channel-likes-user-list"></div>
</section>
`;
document.body.append(overlay);
const modal = overlay.querySelector('.channel-likes-modal');
const status = overlay.querySelector('.channel-likes-modal__status');
const list = overlay.querySelector('.channel-likes-user-list');
const close = () => overlay.remove();
overlay.querySelector('.channel-likes-modal__close')?.addEventListener('click', close);
overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
modal?.addEventListener('click', (event) => event.stopPropagation());
let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining';
let payload = null;
const renderTab = () => {
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
const isActive = button.dataset.likeTab === activeTab;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
if (!payload) return;
const shiningRows = Array.isArray(payload?.shining) ? payload.shining : [];
const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : [];
const otherRows = Array.isArray(payload?.others) ? payload.others : [];
const rows = activeTab === 'shining'
? shiningRows
: activeTab === 'official'
? [...shiningRows, ...officialOnlyRows]
: [...shiningRows, ...officialOnlyRows, ...otherRows];
list.innerHTML = '';
rows.forEach((row) => {
const userButton = document.createElement('button');
userButton.type = 'button';
userButton.className = 'ui-button card row profile-list-row channel-like-user-row';
const avatarRaw = String(row?.avatarAr || '').trim();
userButton.append(renderUserAvatar({
login: String(row?.login || '').trim() || 'unknown',
firstName: String(row?.firstName || ''),
lastName: String(row?.lastName || ''),
avatar: avatarRaw ? { ar: avatarRaw } : null,
size: 'md',
}));
const firstName = String(row?.firstName || '').trim();
const lastName = String(row?.lastName || '').trim();
const login = String(row?.login || '').trim();
const name = [firstName, lastName].filter(Boolean).join(' ') || login;
const text = document.createElement('div');
text.className = 'profile-list-row-text';
text.innerHTML = `<b>${escapeHtml(name)}</b><small>${escapeHtml(login)}</small>`;
userButton.append(text);
userButton.addEventListener('click', () => {
close();
navigate(makeProfileRoute(login));
});
list.append(userButton);
});
status.textContent = rows.length ? '' : 'В этом списке пока никого нет.';
};
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
button.addEventListener('click', () => {
activeTab = button.dataset.likeTab;
renderTab();
});
});
renderTab();
(async () => {
try {
payload = await authService.getMessageLikes(messageRef);
if (!overlay.isConnected) return;
const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0;
const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0;
const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0;
const tabCounts = {
shining: shiningCount,
official: shiningCount + officialOnlyCount,
all: shiningCount + officialOnlyCount + otherCount,
};
Object.entries(tabCounts).forEach(([key, value]) => {
const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`);
if (countEl) countEl.textContent = String(value);
});
renderTab();
} catch (error) {
if (!overlay.isConnected) return;
status.className = 'channel-likes-modal__status is-error';
status.textContent = toUserMessage(error, 'Не удалось загрузить список лайков.');
list.innerHTML = '';
}
})();
}
function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) {
document.querySelectorAll('.channel-like-popup-layer').forEach((el) => el.remove());
const counts = likeCategoryCounts(post);
const layer = document.createElement('div');
layer.className = 'channel-like-popup-layer';
layer.innerHTML = `
<section class="channel-like-popup" role="dialog" aria-label="Лайки сообщения">
<div class="channel-like-popup__title">Лайки</div>
<div class="channel-like-popup__counts">
<button type="button" class="ui-button channel-like-count" data-like-list="shining"><b>${counts.shining}</b><span>Сияющие</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="official"><b>${counts.official}</b><span>Официальные</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="all"><b>${counts.all}</b><span>Все</span></button>
</div>
<button type="button" class="ui-button channel-like-popup__action">${post.reactionState === 'liked' ? 'Убрать свой лайк' : 'Добавить свой лайк'}</button>
<button type="button" class="ui-button channel-like-popup__close">Закрыть</button>
</section>
`;
document.body.append(layer);
const popup = layer.querySelector('.channel-like-popup');
const width = Math.min(330, Math.max(270, window.innerWidth - 24));
popup.style.width = `${width}px`;
const close = () => layer.remove();
layer.addEventListener('click', (event) => { if (event.target === layer) close(); });
popup.addEventListener('click', (event) => event.stopPropagation());
layer.querySelector('.channel-like-popup__close')?.addEventListener('click', close);
layer.querySelectorAll('[data-like-list]').forEach((button) => {
button.addEventListener('click', () => {
const initialTab = button.dataset.likeList;
close();
openMessageLikesListModal({ navigate, messageRef: post.messageRef, initialTab });
});
});
layer.querySelector('.channel-like-popup__action')?.addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
try {
await onToggleLike(post.messageRef, post.reactionState === 'liked' ? 'unlike' : 'like');
close();
} catch (error) {
button.disabled = false;
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
}
});
}
function renderPostCard(post, {
navigate,
selector,
onToggleLike,
onReply,
onRating,
onStatusAction,
onOpenEntrypointMenu,
onRepost,
onShare,
onEdit,
isActive = () => true,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
if (post.isRating) card.classList.add('is-rating');
if (selector && isDiarySelector(selector)) card.classList.add('is-diary-entry');
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'ui-button channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin);
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const titleMain = document.createElement('div');
titleMain.className = 'author-line-main';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = post.authorLogin;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`;
titleMain.append(loginEl, numberEl);
title.append(titleMain);
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openMessageHistoryModal({
title: `История #${post.localNumber}`,
versions: post.versions,
});
});
title.append(editedMarker);
}
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
const typeMeta = getChannelMessageTypeMeta(post.msgSubType);
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
onStatusAction(post);
});
} else if (isEntrypointSubType(post.msgSubType) && typeof onOpenEntrypointMenu === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
onOpenEntrypointMenu(post);
});
} else {
typeButton.classList.add('is-static');
typeButton.disabled = true;
}
headRow.append(typeButton);
}
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 parsedBody = parseMessageAttachments(post.body);
if (isDeletedMessage) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'ui-button deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
event.stopPropagation();
openMessageHistoryModal({
title: `История #${post.localNumber}`,
versions: post.versions,
});
});
card.append(deleted);
return card;
} else {
card.append(headRow);
if (parsedBody.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: post.timestampMs,
}));
}
if (post.isRating) {
const ratingBadge = document.createElement('span');
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
ratingBadge.textContent = 'Оценка';
card.append(ratingBadge);
}
if (post.isStatusAction && selector && isDiarySelector(selector)) {
const statusBadge = document.createElement('span');
statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status';
statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label;
card.append(statusBadge);
if (post.targetText || post.targetAuthorLogin) {
const targetPreview = document.createElement('div');
targetPreview.className = 'channel-message-target-preview';
const targetType = getChannelMessageTypeMeta(post.targetMsgSubType)?.label || 'Материал';
const targetParsed = parseDmTechBlocks(String(post.targetText || '').trim());
const targetText = String(targetParsed.displayText || targetParsed.visibleText || '').trim();
const targetAuthor = String(post.targetAuthorLogin || '').trim();
targetPreview.innerHTML = `
<strong>${escapeHtml(targetType)}</strong>
<span>${escapeHtml(targetAuthor || 'автор')}</span>
<p>${escapeHtml(targetText || 'Без текста')}</p>
`;
card.append(targetPreview);
}
}
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = parsedBody.text;
card.append(body);
}
const refKey = messageRefKey(post.messageRef);
if (refKey) {
card.dataset.messageKey = refKey;
}
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
card.dataset.localNumber = String(Number(post.localNumber));
}
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 = 'ui-button 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" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike });
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'ui-button 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>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, replyButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
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 === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
originalBtn.className = 'ui-button channel-action-item';
originalBtn.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
`;
setActionTitle(originalBtn, 'Оригинал');
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);
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
`;
setActionTitle(detailsButton, 'Данные блокчейна');
detailsButton.addEventListener('click', (event) => {
event.stopPropagation();
openBlockchainDetailsModal(buildBlockchainDetails({
messageRef: post.messageRef,
authorLogin: post.authorLogin,
timestampMs: post.timestampMs,
text: post.body,
raw: post.rawMessage,
localNumber: post.localNumber,
msgSubType: post.msgSubType,
}), { isActive });
});
actions.append(detailsButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'ui-button channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">✏️</span>
`;
editButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
allowEmptyText: parsedBody.attachments.length > 0,
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { 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) {
const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {};
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
const readCount = Math.max(0, Math.min(Number(channelData.readCount || 0), messagesCount));
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 addMessageButton = document.createElement('button');
addMessageButton.type = 'button';
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
addMessageButton.textContent = 'Добавить сообщение';
addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
handlers.onAddMessage();
});
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
const postsByKey = new Map();
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
.map((event) => mapChannelMetaEvent(event, channelData.channel));
let unreadLineInserted = unreadCount === 0;
const feedItems = [
...metaEvents.map((event) => ({
type: 'meta',
timestampMs: event.timestampMs,
blockNumber: Number(event?.messageRef?.blockNumber || 0),
event,
})),
...channelData.posts.map((post) => ({
type: 'post',
timestampMs: post.timestampMs,
blockNumber: Number(post?.messageRef?.blockNumber || 0),
post,
})),
].sort((a, b) => {
const byTime = Number(a.timestampMs || 0) - Number(b.timestampMs || 0);
if (byTime !== 0) return byTime;
return Number(a.blockNumber || 0) - Number(b.blockNumber || 0);
});
if (feedItems.length) {
feedItems.forEach((item) => {
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = 'Новые сообщения';
feed.append(unreadLine);
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
return;
}
const row = renderPostCard(item.post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onRating: handlers.onRating,
onStatusAction: handlers.onStatusAction,
onOpenEntrypointMenu: handlers.onOpenEntrypointMenu,
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
isActive: handlers.isActive,
});
const key = messageRefKey(item.post.messageRef);
if (key) {
postsByKey.set(key, item.post);
}
feed.append(row);
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
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.isDiary) {
screen.append(feed, backButton);
} else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(feed, actionButton);
} else {
screen.append(feed);
}
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
: 0;
const tracker = createChannelReadTracker({
screen,
routeKey,
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelName: channelData.channel?.name || channelData.channel?.channelName,
initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
unreadCount,
messagesCount,
initialSeenCount: readCount,
onPersistError: (error, options = {}) => {
const detail = String(error?.message || '').trim();
const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.';
showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`);
},
onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => {
channelData.readCount = persistedReadCount;
channelData.unreadCount = persistedUnreadCount;
channelData.readStateInitialized = true;
// The "Новые сообщения" divider is a snapshot of the unread boundary at the
// moment this channel view was opened. Persisting read state must not move or
// remove it during the current view session; reopening the channel recalculates it.
const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels'];
for (const group of feedGroups) {
const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : [];
const row = rows.find((item) => (
String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '')
&& Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber)
&& normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash)
));
if (row) {
row.readCount = persistedReadCount;
row.unreadCount = persistedUnreadCount;
row.readStateInitialized = true;
}
}
showStatus('');
},
});
return () => {
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
if (unreadScrollTimer) window.clearTimeout(unreadScrollTimer);
tracker.cleanup();
};
}
function renderSkeleton(screen) {
const wrap = document.createElement('div');
wrap.className = 'stack';
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
screen.append(wrap);
return wrap;
}
export function render({ navigate, route, chrome }) {
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';
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
let cleanupSeenTracking = null;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран канала уже закрыт.');
};
const showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
return;
}
statusBox.textContent = message;
statusBox.style.display = '';
};
let activeChannelData = null;
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
const header = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{
label: '⋯',
title: 'Действия канала',
ariaLabel: 'Открыть меню канала',
className: 'channel-header-more-btn',
menu: {
minWidth: 210,
items: () => {
const apiData = activeChannelData;
if (!apiData) return [];
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
const items = [
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
];
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
if (apiData?.isSubscribed) {
items.push({
label: 'Отписаться от канала',
danger: true,
action: () => unsubscribeFromChannel(apiData),
});
} else {
items.push({
label: 'Подписаться на канал',
action: () => subscribeToChannel(apiData),
});
}
}
return items;
},
},
},
],
});
const channelEntrypointButton = header.querySelector('.topbar__right .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.topbar__right .channel-header-more-btn');
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
chrome?.setTopbar(header);
let activeSelector = null;
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
const subscribeToChannel = async (apiData, event = null) => {
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,
});
if (disposed) return;
try {
await authService.setChannelReadState({
login,
ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
channelName: apiData.channel?.name || apiData.channel?.channelName,
readCount: Math.max(0, Number(apiData.messagesCount || 0)),
timeMs: Date.now(),
storagePwd,
});
} catch (readStateError) {
showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.'));
}
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
softHaptic(15);
showToast('Подписка на канал выполнена');
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
};
const unsubscribeFromChannel = async (apiData) => {
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,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
showToast('Вы отписались от канала');
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
};
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 });
}
if (disposed) return;
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
void refresh();
} catch (error) {
if (disposed) return;
setMessageReactionState(messageRef, previousReaction || 'unliked');
void refresh();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
}
};
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Ответ отправлен');
void refresh();
};
const onRating = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Оценка отправлена');
void refresh();
};
const onStatusAction = async (post) => {
const options = getStatusActionOptionsForTarget(post?.msgSubType);
const typeMeta = getChannelMessageTypeMeta(post?.msgSubType);
if (!options.length || !typeMeta || !post?.messageRef) return;
openStatusActionMenuModal({
targetLabel: typeMeta.label,
options,
isActive: () => !disposed,
onSelect: async (option) => {
openStatusActionCommentModal({
title: option.modalTitle,
submitLabel: option.label,
isActive: () => !disposed,
onSubmit: async (text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockStatusAction({
login,
storagePwd,
message: post.messageRef,
text,
statusSubType: option.subType,
});
ensureActive();
softHaptic(14);
showToast(`${option.label} сохранено`);
void refresh();
},
});
},
});
};
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(),
channelTypeCode: Number(row?.channel?.channelTypeCode ?? 1),
selector,
};
})
.filter(Boolean)
.filter((item) => !isStoriesChannel(item));
};
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 (disposed) return;
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
storagePwd,
channel,
message: messageRef,
text,
});
ensureActive();
if (isSameChannelSelector(channel, activeSelector)) {
pendingScrollByRoute.set(routeKey, '__LAST__');
void refresh();
}
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 (disposed) return;
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
const onAddPost = async (bodyText, msgSubType = 10) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockTextPost({
login,
storagePwd,
channel: activeSelector,
text: bodyText,
msgSubType,
});
ensureActive();
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
void refresh();
};
const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
const isDiaryEdit = isDiarySelector(activeSelector);
if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockEditMessage({
login,
storagePwd,
message: messageRef,
text,
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
void refresh();
};
const onEditChannelMeta = async ({ title, description, avatar }) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockChannelMeta({
login,
storagePwd,
channel: activeSelector,
title,
description,
avatar,
});
ensureActive();
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
softHaptic(12);
showToast('Профиль канала обновлён');
void refresh();
};
screen.append(statusBox);
const clearContent = () => {
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null;
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
const clearOwnedModal = () => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return;
const ownedSelector = [
'#about-channel-modal',
'#blockchain-details-modal',
'#channel-entrypoint-history-modal',
'#channel-entrypoint-menu-modal',
'#channel-message-modal',
'#channel-status-action-modal',
'#channel-status-menu-modal',
'#edit-channel-modal',
'#edit-message-modal',
'#message-history-modal',
'#reply-modal',
'#repost-modal',
].join(',');
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
activeChannelData = null;
activeSelector = null;
showStatus('');
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
channelHeaderButton.onclick = null;
if (channelMoreButton) channelMoreButton.disabled = true;
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
channelEntrypointButton.onclick = null;
}
const skeleton = renderSkeleton(screen);
try {
const apiData = await loadFromApi(route, channelId);
if (disposed || seq !== refreshSeq) return;
activeSelector = apiData?.selector || null;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
openEntrypointHistoryModal({
channelTitle: titleLabel,
posts: apiData?.posts,
onSelect: (post) => {
if (!flashAndScrollToMessage(post?.messageRef)) {
showToast('Не удалось найти запись оглавления в ленте');
}
},
});
};
if (channelHeaderButton) {
channelHeaderButton.textContent = titleLabel;
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
if (aboutRoute) navigate(aboutRoute);
};
}
activeChannelData = apiData;
if (channelMoreButton) {
channelMoreButton.disabled = false;
}
if (channelEntrypointButton) {
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
channelEntrypointButton.hidden = !canShowEntrypointButton;
channelEntrypointButton.disabled = !canShowEntrypointButton;
channelEntrypointButton.onclick = () => {
const latestEntrypoint = entrypointPosts[0];
if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) {
showToast('Не удалось найти актуальное оглавление');
}
};
}
skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
showStatus,
isActive: () => !disposed,
onAddMessage: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
});
},
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, 'Не удалось отправить ответ.'));
}
},
onRating: async (messageRef, text) => {
try {
await onRating(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отправить оценку.'));
}
},
onStatusAction: async (post) => {
try {
await onStatusAction(post);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось записать действие.'));
}
},
onOpenEntrypointMenu: () => {
openEntrypointMenuModal({
onShowHistory: openEntrypointHistory,
});
},
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: (event) => subscribeToChannel(apiData, event),
});
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error);
return;
}
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), () => {
void refresh();
});
}
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
};
void refresh();
return screen;
}