SHA256
2495 lines
98 KiB
JavaScript
2495 lines
98 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
||
import {
|
||
authService,
|
||
getMessageReactionState,
|
||
setChannelsFeed,
|
||
setMessageReactionState,
|
||
state,
|
||
} from '../state.js';
|
||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||
import {
|
||
animatePress,
|
||
createSkeletonCard,
|
||
formatRelativeTime,
|
||
longPressFeel,
|
||
shareOrCopyLink,
|
||
showToast,
|
||
softHaptic,
|
||
} from '../services/channels-ux.js';
|
||
import { 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,
|
||
} from '../services/shine-routes.js';
|
||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||
|
||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||
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: 'small',
|
||
className: 'channel-message-avatar',
|
||
title,
|
||
});
|
||
if (!cleanLogin) return avatarEl;
|
||
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||
if (!avatarEl.isConnected) return;
|
||
const upgraded = renderUserAvatar({
|
||
login: cleanLogin,
|
||
avatar: snapshot?.avatar?.txId
|
||
? {
|
||
ar: String(snapshot.avatar.txId || '').trim(),
|
||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||
}
|
||
: null,
|
||
size: 'small',
|
||
className: 'channel-message-avatar',
|
||
title,
|
||
});
|
||
avatarEl.replaceWith(upgraded);
|
||
});
|
||
return avatarEl;
|
||
}
|
||
|
||
function isChannelsDemoMode() {
|
||
try {
|
||
const qs = new URLSearchParams(window.location.search);
|
||
if (qs.get('channelsDemo') === '1') return true;
|
||
return localStorage.getItem('shine-channels-demo') === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function encodeRoutePart(value = '') {
|
||
return encodeURIComponent(String(value));
|
||
}
|
||
|
||
function normalizeRouteHash(hash) {
|
||
const normalized = String(hash || '').trim().toLowerCase();
|
||
return normalized || '0';
|
||
}
|
||
|
||
function normalizeMessageHash(hash) {
|
||
const normalized = String(hash || '').trim().toLowerCase();
|
||
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
|
||
if (/^0+$/.test(normalized)) return '';
|
||
return normalized;
|
||
}
|
||
|
||
function toSafeInt(value) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function looksLikeBlockchainName(value) {
|
||
const raw = String(value || '').trim();
|
||
return /^[^-]+-\d+$/.test(raw);
|
||
}
|
||
|
||
function makeReactionActionKey(messageRef) {
|
||
const login = String(state.session.login || '').trim().toLowerCase();
|
||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||
const blockNumber = Number(messageRef?.blockNumber);
|
||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||
}
|
||
|
||
function messageRefKey(messageRef) {
|
||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||
const blockNumber = Number(messageRef?.blockNumber);
|
||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||
}
|
||
|
||
function parseMessageRefKey(key) {
|
||
const raw = String(key || '').trim();
|
||
if (!raw) return null;
|
||
const parts = raw.split(':');
|
||
if (parts.length !== 3) return null;
|
||
const blockNumber = Number(parts[1]);
|
||
const blockHash = normalizeMessageHash(parts[2]);
|
||
if (!parts[0] || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||
return {
|
||
blockchainName: parts[0],
|
||
blockNumber,
|
||
blockHash,
|
||
};
|
||
}
|
||
|
||
function blockRefToMessageKey(blockRef, fallbackBch = '') {
|
||
const blockNumber = toSafeInt(blockRef?.blockNumber);
|
||
const blockHash = normalizeMessageHash(blockRef?.blockHash);
|
||
const blockchainName = String(fallbackBch || '').trim();
|
||
if (!blockchainName || blockNumber == null || !blockHash) return '';
|
||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||
}
|
||
|
||
function buildAbsoluteRouteUrl(routePath = '') {
|
||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||
const url = new URL(window.location.href);
|
||
url.pathname = `/${cleanRoute}`;
|
||
url.hash = '';
|
||
return url.toString();
|
||
}
|
||
|
||
function buildSelectorFromRoute(route, channelId) {
|
||
const params = route?.params || {};
|
||
|
||
if (params.ownerBlockchainName && params.channelName) {
|
||
return {
|
||
ownerBlockchainName: String(params.ownerBlockchainName || '').trim(),
|
||
channelName: String(params.channelName || '').trim(),
|
||
};
|
||
}
|
||
|
||
if (params.ownerBlockchainName) {
|
||
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
||
if (rootBlockNumber != null) {
|
||
return {
|
||
ownerBlockchainName: String(params.ownerBlockchainName),
|
||
channelRootBlockNumber: rootBlockNumber,
|
||
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||
};
|
||
}
|
||
}
|
||
|
||
const summary = channelId ? state.channelsIndex[channelId] : null;
|
||
if (!summary) return null;
|
||
return {
|
||
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
||
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
|
||
channelRootBlockHash: normalizeRouteHash(summary.channel?.channelRoot?.blockHash),
|
||
};
|
||
}
|
||
|
||
function buildThreadRoute(messageRef, selector) {
|
||
if (!messageRef || !selector) return '';
|
||
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
|
||
return makeShineMessageRoute({
|
||
ownerLogin,
|
||
messageBlockchainName: messageRef.blockchainName,
|
||
messageBlockNumber: messageRef.blockNumber,
|
||
});
|
||
}
|
||
|
||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||
const name = String(channelName || '').trim();
|
||
return `${ownerBch}/${name}`;
|
||
}
|
||
|
||
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 = 72) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'channel-profile-avatar';
|
||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||
const txId = String(channel?.avaAr || '').trim();
|
||
if (txId) {
|
||
const img = document.createElement('img');
|
||
img.alt = 'Аватар канала';
|
||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||
wrap.append(img);
|
||
} else {
|
||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||
}
|
||
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, 86));
|
||
|
||
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) {
|
||
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);
|
||
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 = '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' }) {
|
||
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));
|
||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||
close();
|
||
} catch (error) {
|
||
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 (!item) return;
|
||
attachments.push(item);
|
||
renderDraftAttachments(attachmentsEl, attachments);
|
||
} catch (error) {
|
||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||
}
|
||
});
|
||
|
||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||
|
||
if (textEl) textEl.focus();
|
||
}
|
||
|
||
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
|
||
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());
|
||
close();
|
||
} catch (error) {
|
||
setBusy(false);
|
||
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
|
||
}
|
||
});
|
||
|
||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||
if (textEl) textEl.focus();
|
||
}
|
||
|
||
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
|
||
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();
|
||
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(() => target.classList.add('is-focus-flash'), 60);
|
||
window.setTimeout(() => 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 = '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 }) {
|
||
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 });
|
||
close();
|
||
} catch (error) {
|
||
setBusy(false);
|
||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||
}
|
||
});
|
||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||
if (textEl) textEl.focus();
|
||
}
|
||
|
||
function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||
const root = document.getElementById('modal-root');
|
||
root.innerHTML = `
|
||
<div class="modal" id="channel-message-modal">
|
||
<div class="modal-card stack">
|
||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||
<p class="meta-muted">${channelName}</p>
|
||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||
<div class="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_EXERCISE}">Упражнение</option>
|
||
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
|
||
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</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,
|
||
});
|
||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||
close();
|
||
} catch (error) {
|
||
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 (!item) return;
|
||
attachments.push(item);
|
||
renderDraftAttachments(attachmentsEl, attachments);
|
||
} catch (error) {
|
||
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 }) {
|
||
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);
|
||
close();
|
||
} catch (error) {
|
||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||
}
|
||
});
|
||
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
|
||
try {
|
||
await onDelete();
|
||
close();
|
||
} catch (error) {
|
||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||
}
|
||
});
|
||
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),
|
||
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 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) {
|
||
let unreadCount = 0;
|
||
let messagesCount = 0;
|
||
|
||
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);
|
||
selector = {
|
||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
||
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
|
||
channelName: selector.channelName,
|
||
};
|
||
}
|
||
|
||
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
|
||
throw new Error('Не удалось определить канал из адреса страницы.');
|
||
}
|
||
|
||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||
let reverseChannelMissingWarning = '';
|
||
let mergedMessages = [...messages];
|
||
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, 200, 'asc', currentSessionLogin);
|
||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||
mergedMessages = mergedMessages.concat(reverseMessages);
|
||
} else {
|
||
reverseChannelMissingWarning = `У собеседника ${channelName} пока не создан ответный персональный чат.`;
|
||
}
|
||
}
|
||
|
||
const posts = mergedMessages
|
||
.map((message, index) => mapApiMessageToPost(message, selector, index + 1))
|
||
.sort((a, b) => {
|
||
const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0);
|
||
if (byTime !== 0) return byTime;
|
||
const aNum = Number(a?.messageRef?.blockNumber || 0);
|
||
const bNum = Number(b?.messageRef?.blockNumber || 0);
|
||
return aNum - bNum;
|
||
})
|
||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
|
||
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
||
const isSubscribed = isAuthorized && followedRows.some((row) => (
|
||
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
|
||
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
||
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
||
));
|
||
|
||
return {
|
||
channel: {
|
||
name: payload.channel?.channelName || 'неизвестный канал',
|
||
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,
|
||
isOwnChannel,
|
||
isSubscribed,
|
||
selector,
|
||
};
|
||
}
|
||
|
||
function renderLoadError(screen, navigate, message, onRetry) {
|
||
const card = document.createElement('div');
|
||
card.className = 'card stack channels-status';
|
||
card.innerHTML = `
|
||
<strong>Не удалось загрузить канал</strong>
|
||
<p class="meta-muted">${message || 'Проверьте подключение к серверу и повторите попытку.'}</p>
|
||
`;
|
||
|
||
const retry = document.createElement('button');
|
||
retry.type = 'button';
|
||
retry.className = 'primary-btn';
|
||
retry.textContent = 'Повторить';
|
||
retry.addEventListener('click', onRetry);
|
||
|
||
const back = document.createElement('button');
|
||
back.type = 'button';
|
||
back.className = 'secondary-btn';
|
||
back.textContent = 'Назад к каналам';
|
||
back.addEventListener('click', () => navigate('channels-list'));
|
||
|
||
card.append(retry, back);
|
||
screen.append(card);
|
||
}
|
||
|
||
function renderDemoFallback(screen, navigate, error) {
|
||
const info = document.createElement('div');
|
||
info.className = 'card stack';
|
||
info.innerHTML = `
|
||
<strong>Включен демо-режим</strong>
|
||
<p class="meta-muted">Данные канала с сервера недоступны. Показан демо-контент.</p>
|
||
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
||
`;
|
||
screen.append(info);
|
||
|
||
const back = document.createElement('button');
|
||
back.className = 'secondary-btn';
|
||
back.textContent = 'Назад к каналам';
|
||
back.addEventListener('click', () => navigate('channels-list'));
|
||
screen.append(back);
|
||
}
|
||
|
||
function scrollChannelToBottom(screen, smooth = true) {
|
||
const feed = screen.querySelector('.channel-feed');
|
||
if (feed) {
|
||
feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||
}
|
||
const appScreen = document.getElementById('app-screen');
|
||
if (appScreen) {
|
||
appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||
return;
|
||
}
|
||
window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||
}
|
||
|
||
function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
||
const target = pendingScrollByRoute.get(routeKey);
|
||
if (!target && !forceBottom) return;
|
||
|
||
const doScroll = () => {
|
||
if (!target && forceBottom) {
|
||
scrollChannelToBottom(screen, false);
|
||
return;
|
||
}
|
||
|
||
if (target === '__LAST__') {
|
||
scrollChannelToBottom(screen, true);
|
||
pendingScrollByRoute.delete(routeKey);
|
||
return;
|
||
}
|
||
|
||
const element = screen.querySelector(`[data-message-key="${target}"]`);
|
||
if (element) {
|
||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
pendingScrollByRoute.delete(routeKey);
|
||
}
|
||
};
|
||
|
||
setTimeout(doScroll, 20);
|
||
}
|
||
|
||
function 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 renderPostCard(post, {
|
||
navigate,
|
||
selector,
|
||
onToggleLike,
|
||
onReply,
|
||
onRating,
|
||
onStatusAction,
|
||
onOpenEntrypointMenu,
|
||
onRepost,
|
||
onShare,
|
||
onEdit,
|
||
}) {
|
||
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 = '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 = '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 = '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 = '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;
|
||
}
|
||
card.classList.add('is-counters-visible');
|
||
|
||
if (!post.messageRef || !selector) return card;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'channel-message-actions';
|
||
|
||
const actionKey = makeReactionActionKey(post.messageRef);
|
||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||
|
||
const likeButton = document.createElement('button');
|
||
likeButton.type = 'button';
|
||
likeButton.className = 'channel-action-item channel-action-like';
|
||
const isLiked = post.reactionState === 'liked';
|
||
if (isLiked) likeButton.classList.add('is-liked');
|
||
likeButton.innerHTML = `
|
||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||
`;
|
||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||
likeButton.disabled = isPending;
|
||
likeButton.addEventListener('click', async (event) => {
|
||
event.stopPropagation();
|
||
animatePress(event.currentTarget);
|
||
if (isPending) return;
|
||
if (!isLiked) {
|
||
const ok = window.confirm('Поставить лайк?');
|
||
if (!ok) return;
|
||
}
|
||
await longPressFeel(event.currentTarget, 130);
|
||
likeButton.disabled = true;
|
||
setActionTitle(likeButton, 'Лайк...');
|
||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||
});
|
||
|
||
const replyButton = document.createElement('button');
|
||
replyButton.type = 'button';
|
||
replyButton.className = 'channel-action-item channel-action-reply';
|
||
replyButton.innerHTML = `
|
||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||
<span class="channel-action-label">Ответить</span>
|
||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||
`;
|
||
setActionTitle(replyButton, 'Ответить');
|
||
replyButton.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
animatePress(event.currentTarget);
|
||
openReplyModal({
|
||
navigate,
|
||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||
});
|
||
});
|
||
const ratingButton = document.createElement('button');
|
||
ratingButton.type = 'button';
|
||
ratingButton.className = 'channel-action-item channel-action-rating';
|
||
ratingButton.innerHTML = `
|
||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||
<span class="channel-action-label">Оценка</span>
|
||
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
||
`;
|
||
setActionTitle(ratingButton, 'Оценка');
|
||
ratingButton.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
animatePress(event.currentTarget);
|
||
openReplyModal({
|
||
navigate,
|
||
mode: 'rating',
|
||
onSubmit: async (text) => onRating(post.messageRef, text),
|
||
});
|
||
});
|
||
// Репосты временно отключены до будущей реализации.
|
||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||
actions.append(likeButton, replyButton, ratingButton);
|
||
|
||
const shareButton = document.createElement('button');
|
||
shareButton.type = 'button';
|
||
shareButton.className = 'channel-action-item channel-action-share';
|
||
shareButton.innerHTML = `
|
||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||
<span class="channel-action-label">Отправить</span>
|
||
`;
|
||
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 = '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 = '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,
|
||
}));
|
||
});
|
||
actions.append(detailsButton);
|
||
if (post.isOwnMessage) {
|
||
const editButton = document.createElement('button');
|
||
editButton.type = 'button';
|
||
editButton.className = 'channel-action-item';
|
||
editButton.setAttribute('aria-label', 'Редактировать');
|
||
editButton.title = 'Редактировать';
|
||
editButton.innerHTML = `
|
||
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
||
`;
|
||
editButton.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
animatePress(event.currentTarget);
|
||
openEditMessageModal({
|
||
initialText: String(post.body || '').trim() === 'удалено' ? '' : 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) {
|
||
if (channelData.reverseChannelMissingWarning) {
|
||
const reverseWarning = document.createElement('p');
|
||
reverseWarning.className = 'channel-head-meta';
|
||
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
||
screen.append(reverseWarning);
|
||
}
|
||
|
||
if (Number(channelData.unreadCount || 0) > 0) {
|
||
const unreadLine = document.createElement('div');
|
||
unreadLine.className = 'card channel-unread-line';
|
||
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
|
||
screen.append(unreadLine);
|
||
}
|
||
|
||
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));
|
||
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 (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,
|
||
});
|
||
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(actionButton, feed, backButton);
|
||
} else {
|
||
screen.append(feed, backButton);
|
||
}
|
||
|
||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
|
||
return () => {
|
||
// noop
|
||
};
|
||
}
|
||
|
||
function renderSkeleton(screen) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'stack';
|
||
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
|
||
screen.append(wrap);
|
||
return wrap;
|
||
}
|
||
|
||
export function render({ navigate, route, 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';
|
||
const appScreen = document.getElementById('app-screen');
|
||
appScreen?.classList.add('channels-scroll-clean');
|
||
|
||
const statusBox = document.createElement('div');
|
||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||
statusBox.style.display = 'none';
|
||
|
||
const showStatus = (message) => {
|
||
if (!message) {
|
||
statusBox.style.display = 'none';
|
||
statusBox.textContent = '';
|
||
return;
|
||
}
|
||
statusBox.textContent = message;
|
||
statusBox.style.display = '';
|
||
};
|
||
|
||
const header = renderHeader({
|
||
title: '',
|
||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||
rightActions: [
|
||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||
],
|
||
});
|
||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||
if (channelHeaderButton) {
|
||
channelHeaderButton.disabled = true;
|
||
}
|
||
if (channelEntrypointButton) {
|
||
channelEntrypointButton.disabled = true;
|
||
channelEntrypointButton.hidden = true;
|
||
}
|
||
chrome?.setTopbar(header);
|
||
|
||
const rerender = () => {
|
||
const current = document.querySelector('section.channels-screen--channel');
|
||
if (!current) return;
|
||
const next = render({ navigate, route });
|
||
current.cleanup?.();
|
||
current.replaceWith(next);
|
||
};
|
||
let activeSelector = null;
|
||
|
||
const requireSigningSession = () => {
|
||
const login = state.session.login;
|
||
const storagePwd = state.session.storagePwdInMemory;
|
||
if (!login || !storagePwd) {
|
||
state.authReturnHash = window.location.pathname || '/channels';
|
||
navigate('login-view');
|
||
throw new Error('Для этого действия нужно войти');
|
||
}
|
||
return { login, storagePwd };
|
||
};
|
||
|
||
const onToggleLike = async (messageRef, action) => {
|
||
const actionKey = makeReactionActionKey(messageRef);
|
||
if (!actionKey) {
|
||
throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||
}
|
||
if (pendingReactionActions.has(actionKey)) return;
|
||
|
||
const previousReaction = getMessageReactionState(messageRef);
|
||
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
|
||
pendingReactionActions.add(actionKey);
|
||
|
||
try {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
if (action === 'unlike') {
|
||
await authService.addBlockUnlike({ login, storagePwd, message: messageRef });
|
||
} else {
|
||
await authService.addBlockLike({ login, storagePwd, message: messageRef });
|
||
}
|
||
setMessageReactionState(messageRef, nextReaction);
|
||
softHaptic(10);
|
||
rerender();
|
||
} catch (error) {
|
||
setMessageReactionState(messageRef, previousReaction || 'unliked');
|
||
rerender();
|
||
throw error;
|
||
} finally {
|
||
pendingReactionActions.delete(actionKey);
|
||
}
|
||
};
|
||
|
||
const onReply = async (messageRef, text) => {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
|
||
|
||
const scrollTarget = messageRefKey(messageRef);
|
||
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||
|
||
softHaptic(15);
|
||
showToast('Ответ отправлен');
|
||
rerender();
|
||
};
|
||
|
||
const onRating = async (messageRef, text) => {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
|
||
|
||
const scrollTarget = messageRefKey(messageRef);
|
||
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||
|
||
softHaptic(15);
|
||
showToast('Оценка отправлена');
|
||
rerender();
|
||
};
|
||
|
||
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,
|
||
onSelect: async (option) => {
|
||
openStatusActionCommentModal({
|
||
title: option.modalTitle,
|
||
submitLabel: option.label,
|
||
onSubmit: async (text) => {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
await authService.addBlockStatusAction({
|
||
login,
|
||
storagePwd,
|
||
message: post.messageRef,
|
||
text,
|
||
statusSubType: option.subType,
|
||
});
|
||
softHaptic(14);
|
||
showToast(`${option.label} сохранено`);
|
||
rerender();
|
||
},
|
||
});
|
||
},
|
||
});
|
||
};
|
||
|
||
const loadOwnedChannelsForRepost = async (login) => {
|
||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
||
return rows
|
||
.map((row) => {
|
||
const selector = {
|
||
ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(),
|
||
channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber),
|
||
channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash),
|
||
};
|
||
if (!selector.ownerBlockchainName || !Number.isFinite(selector.channelRootBlockNumber) || selector.channelRootBlockNumber < 0) {
|
||
return null;
|
||
}
|
||
return {
|
||
ownerLogin: String(row?.channel?.ownerLogin || '').trim(),
|
||
channelName: String(row?.channel?.channelName || '').trim(),
|
||
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 (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
|
||
openRepostModal({
|
||
navigate,
|
||
channels,
|
||
onSubmit: async ({ channel, text }) => {
|
||
await authService.addBlockRepost({
|
||
login,
|
||
storagePwd,
|
||
channel,
|
||
message: messageRef,
|
||
text,
|
||
});
|
||
if (isSameChannelSelector(channel, activeSelector)) {
|
||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||
rerender();
|
||
}
|
||
softHaptic(12);
|
||
showToast('Репост опубликован');
|
||
},
|
||
});
|
||
};
|
||
|
||
const onShare = async (routePath) => {
|
||
try {
|
||
const routeToShare = String(routePath || '').trim();
|
||
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||
const result = await shareOrCopyLink({
|
||
title: 'SHiNE · Каналы',
|
||
text: 'Тред из канала SHiNE',
|
||
url: buildAbsoluteRouteUrl(routeToShare),
|
||
});
|
||
if (result === 'copied') showToast('Ссылка скопирована');
|
||
if (result === 'shared') showToast('Ссылка передана');
|
||
if (result === 'shared' || result === 'copied') softHaptic(10);
|
||
} catch (error) {
|
||
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
|
||
}
|
||
};
|
||
|
||
const onAddPost = async (bodyText, 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,
|
||
});
|
||
|
||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||
softHaptic(15);
|
||
showToast('Сообщение отправлено');
|
||
rerender();
|
||
};
|
||
|
||
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,
|
||
});
|
||
softHaptic(12);
|
||
showToast('Сообщение обновлено');
|
||
rerender();
|
||
};
|
||
|
||
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,
|
||
});
|
||
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
|
||
softHaptic(12);
|
||
showToast('Профиль канала обновлён');
|
||
rerender();
|
||
};
|
||
|
||
screen.append(statusBox);
|
||
|
||
const skeleton = renderSkeleton(screen);
|
||
|
||
let cleanupSeenTracking = null;
|
||
|
||
(async () => {
|
||
try {
|
||
const apiData = await loadFromApi(route, channelId);
|
||
activeSelector = apiData?.selector || null;
|
||
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
|
||
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
|
||
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
|
||
void authService.upsertUserSetting({
|
||
login: state.session.login,
|
||
settingType: 1,
|
||
settingKey,
|
||
timeMs: Date.now(),
|
||
valueText: '',
|
||
valueNum: lastSeenCount,
|
||
storagePwd: state.session.storagePwdInMemory,
|
||
}).catch(() => {});
|
||
}
|
||
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);
|
||
openAboutChannelModal(apiData.channel, {
|
||
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
|
||
onEdit: () => openEditChannelModal({
|
||
channel: apiData.channel,
|
||
onSave: onEditChannelMeta,
|
||
}),
|
||
});
|
||
};
|
||
}
|
||
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, {
|
||
onAddMessage: () => {
|
||
openAddMessageModal({
|
||
channelName: apiData?.channel?.name || '',
|
||
navigate,
|
||
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: async (event) => {
|
||
animatePress(event?.currentTarget);
|
||
try {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
|
||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||
if (!ok) return;
|
||
|
||
await authService.addBlockFollowChannel({
|
||
login,
|
||
storagePwd,
|
||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||
unfollow: false,
|
||
});
|
||
|
||
const feed = await authService.listSubscriptionsFeed(login, 200);
|
||
setChannelsFeed(feed, state.channelsIndex);
|
||
softHaptic(15);
|
||
showToast('Подписка на канал выполнена');
|
||
rerender();
|
||
} catch (error) {
|
||
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
|
||
}
|
||
},
|
||
});
|
||
} catch (error) {
|
||
skeleton.remove();
|
||
if (isChannelsDemoMode()) {
|
||
renderDemoFallback(screen, navigate, error);
|
||
return;
|
||
}
|
||
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
||
}
|
||
})();
|
||
|
||
screen.cleanup = () => {
|
||
appScreen?.classList.remove('channels-scroll-clean');
|
||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||
};
|
||
|
||
return screen;
|
||
}
|