SHA256
1554 lines
61 KiB
JavaScript
1554 lines
61 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
|
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
|
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
|
import { captureClientError } from '../services/client-error-reporter.js';
|
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
|
import {
|
|
animatePress,
|
|
createSkeletonCard,
|
|
longPressFeel,
|
|
shareOrCopyLink,
|
|
showToast,
|
|
softHaptic,
|
|
} from '../services/channels-ux.js';
|
|
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.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, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
|
|
|
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
|
const MSG_SUBTYPE_TEXT_POST = 10;
|
|
const MSG_SUBTYPE_TEXT_RATING = 30;
|
|
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 pendingReactionActions = new Set();
|
|
const pendingThreadScroll = new Map();
|
|
const threadAvatarSnapshotCache = new Map();
|
|
const threadAvatarPendingByLogin = new Map();
|
|
|
|
async function loadThreadAvatarSnapshot(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) return null;
|
|
const key = cleanLogin.toLowerCase();
|
|
if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key);
|
|
if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key);
|
|
const pending = loadProfileSnapshot(cleanLogin)
|
|
.then((snapshot) => {
|
|
threadAvatarSnapshotCache.set(key, snapshot || null);
|
|
threadAvatarPendingByLogin.delete(key);
|
|
return snapshot || null;
|
|
})
|
|
.catch(() => {
|
|
threadAvatarSnapshotCache.set(key, null);
|
|
threadAvatarPendingByLogin.delete(key);
|
|
return null;
|
|
});
|
|
threadAvatarPendingByLogin.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
function createThreadAvatar(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 loadThreadAvatarSnapshot(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 logThreadRuntimeError(stage, error, context = {}) {
|
|
const message = String(error?.message || error || 'thread runtime error');
|
|
console.error(`[channel-thread-view:${stage}]`, error, context);
|
|
captureClientError({
|
|
kind: 'channels_thread_runtime',
|
|
message,
|
|
stack: error?.stack || '',
|
|
context: { stage, ...context },
|
|
});
|
|
}
|
|
|
|
function encodeRoutePart(value = '') {
|
|
return encodeURIComponent(String(value));
|
|
}
|
|
|
|
function normalizeRouteHash(hash) {
|
|
const normalized = String(hash || '').trim().toLowerCase();
|
|
return normalized || '0';
|
|
}
|
|
|
|
function normalizeMessageHash(hash) {
|
|
const normalized = String(hash || '').trim().toLowerCase();
|
|
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
|
|
if (/^0+$/.test(normalized)) return '';
|
|
return normalized;
|
|
}
|
|
|
|
function toSafeInt(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function looksLikeBlockchainName(value) {
|
|
const raw = String(value || '').trim();
|
|
return /^[^-]+-\d+$/.test(raw);
|
|
}
|
|
|
|
function makeReactionActionKey(messageRef) {
|
|
const login = String(state.session.login || '').trim().toLowerCase();
|
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
|
const blockNumber = Number(messageRef?.blockNumber);
|
|
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
|
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
|
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
|
}
|
|
|
|
function messageRefKey(messageRef) {
|
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
|
const blockNumber = Number(messageRef?.blockNumber);
|
|
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
|
}
|
|
|
|
function buildAbsoluteRouteUrl(routePath = '') {
|
|
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
|
const url = new URL(window.location.href);
|
|
url.pathname = `/${cleanRoute}`;
|
|
url.hash = '';
|
|
return url.toString();
|
|
}
|
|
|
|
function parseThreadSelector(route) {
|
|
const params = route?.params || {};
|
|
if (params.ownerBlockchainName && params.channelName && params.messageBlockNumber) {
|
|
return {
|
|
short: {
|
|
ownerBlockchainName: String(params.ownerBlockchainName || '').trim(),
|
|
channelName: String(params.channelName || '').trim(),
|
|
},
|
|
message: {
|
|
blockchainName: '',
|
|
blockNumber: toSafeInt(params.messageBlockNumber),
|
|
blockHash: normalizeRouteHash(params.messageBlockHash),
|
|
},
|
|
channel: {
|
|
ownerBlockchainName: '',
|
|
channelRootBlockNumber: null,
|
|
channelRootBlockHash: '0',
|
|
},
|
|
};
|
|
}
|
|
const blockNumber = toSafeInt(params.messageBlockNumber);
|
|
if (!params.messageBlockchainName || blockNumber == null) return null;
|
|
|
|
return {
|
|
message: {
|
|
blockchainName: String(params.messageBlockchainName),
|
|
blockNumber,
|
|
blockHash: normalizeRouteHash(params.messageBlockHash),
|
|
},
|
|
channel: {
|
|
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
|
channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
|
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
|
},
|
|
};
|
|
}
|
|
|
|
function allFeedSummaries() {
|
|
const feed = state.channelsFeed || {};
|
|
return [
|
|
...(feed.ownedChannels || []),
|
|
...(feed.followedUsersChannels || []),
|
|
...(feed.followedChannels || []),
|
|
].filter((summary) => {
|
|
const typeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
|
const channelName = String(summary?.channel?.channelName || '').trim().toLowerCase();
|
|
return typeCode !== 0 && channelName !== 'stories';
|
|
});
|
|
}
|
|
|
|
function resolveChannelDisplayName(channelSelector) {
|
|
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
|
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
|
if (!channelSelector?.ownerBlockchainName || rootNumber == null) return '';
|
|
const ownerBch = String(channelSelector.ownerBlockchainName);
|
|
const rootNo = Number(rootNumber);
|
|
const rootHash = normalizeRouteHash(rootHashRaw);
|
|
|
|
const found = allFeedSummaries().find((summary) => (
|
|
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
|
&& Number(summary?.channel?.channelRoot?.blockNumber) === rootNo
|
|
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
|
));
|
|
if (!found) return '';
|
|
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
|
}
|
|
|
|
function resolveChannelHeadingFromNode(node) {
|
|
const info = node?.channelInfo;
|
|
const ownerBch = String(info?.ownerBlockchainName || '').trim();
|
|
const rootBlockNumber = Number(info?.channelRoot?.blockNumber);
|
|
if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return '';
|
|
if (rootBlockNumber === 0) return 'История пользователя';
|
|
|
|
const label = resolveChannelDisplayName({
|
|
ownerBlockchainName: ownerBch,
|
|
channelRootBlockNumber: rootBlockNumber,
|
|
channelRootBlockHash: '0',
|
|
});
|
|
const slashIndex = label.indexOf('/');
|
|
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim();
|
|
if (!channelName) return 'Сообщение в канале';
|
|
return `Сообщение в канале ${channelName}`;
|
|
}
|
|
|
|
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 isEditableAsChannelPostSubType(msgSubType) {
|
|
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST
|
|
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE
|
|
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE
|
|
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE;
|
|
}
|
|
|
|
function extractChannelContextFromThreadPayload(payload) {
|
|
const focusInfo = payload?.focus?.channelInfo;
|
|
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
|
|
return {
|
|
ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(),
|
|
channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber),
|
|
channelRootBlockHash: '0',
|
|
};
|
|
}
|
|
|
|
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
|
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
|
|
const info = ancestors[i]?.channelInfo;
|
|
if (info?.ownerBlockchainName && info?.channelRoot?.blockNumber != null) {
|
|
return {
|
|
ownerBlockchainName: String(info.ownerBlockchainName || '').trim(),
|
|
channelRootBlockNumber: Number(info.channelRoot.blockNumber),
|
|
channelRootBlockHash: '0',
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolveChannelDisplayNameFromServer(channelSelector) {
|
|
const ownerBch = String(channelSelector?.ownerBlockchainName || '').trim();
|
|
const rootNo = Number(channelSelector?.channelRootBlockNumber);
|
|
if (!ownerBch || !Number.isFinite(rootNo) || rootNo < 0) return '';
|
|
|
|
const ownerLogin = extractLoginFromBlockchainName(ownerBch);
|
|
if (!ownerLogin) return '';
|
|
|
|
try {
|
|
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
|
|
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
|
const row = rows.find((item) => (
|
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch.toLowerCase()
|
|
&& Number(item?.channel?.channelRoot?.blockNumber) === rootNo
|
|
));
|
|
if (!row?.channel?.channelName) return '';
|
|
|
|
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
|
|
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function buildThreadRouteFromTarget(target, selector) {
|
|
if (!target) return '';
|
|
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
|
return makeShineMessageRoute({
|
|
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
|
|
messageBlockchainName: target.blockchainName,
|
|
messageBlockNumber: target.blockNumber,
|
|
});
|
|
}
|
|
|
|
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
|
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
|
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
|
return makeShineChannelRoute({
|
|
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
|
ownerBlockchainName: ownerBch,
|
|
channelName: selector.short.channelName,
|
|
});
|
|
}
|
|
const label = String(resolvedChannelLabel || '').trim();
|
|
const slashIndex = label.indexOf('/');
|
|
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
|
return makeShineChannelRoute({
|
|
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
|
ownerBlockchainName: ownerBch,
|
|
channelName,
|
|
});
|
|
}
|
|
|
|
function resolveThreadBackRoute(selector, resolvedChannelLabel = '') {
|
|
return buildChannelRouteFromThread(selector, resolvedChannelLabel) || 'channels-list';
|
|
}
|
|
|
|
function resolveThreadPreviousInChannels(selector, resolvedChannelLabel = '') {
|
|
const previousPath = String(getPreviousTrackedPath() || '').trim();
|
|
if (previousPath) {
|
|
const previousRoute = parseRouteFromPath(previousPath);
|
|
const previousPageId = String(previousRoute?.pageId || '').trim();
|
|
if (
|
|
previousPageId === 'channel-thread-view'
|
|
|| previousPageId === 'channel-view'
|
|
) {
|
|
return previousPath;
|
|
}
|
|
}
|
|
return resolveThreadBackRoute(selector, resolvedChannelLabel);
|
|
}
|
|
|
|
function buildTargetFromNode(node) {
|
|
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
|
const blockNumber = Number(node?.messageRef?.blockNumber);
|
|
const blockHash = normalizeMessageHash(node?.messageRef?.blockHash);
|
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
|
return { blockchainName, blockNumber, blockHash };
|
|
}
|
|
|
|
function buildRepostTargetFromNode(node) {
|
|
const blockchainName = String(node?.targetBlockchainName || '').trim();
|
|
const blockNumber = Number(node?.targetBlockNumber);
|
|
const blockHash = normalizeMessageHash(node?.targetBlockHash);
|
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
|
return { blockchainName, blockNumber, blockHash };
|
|
}
|
|
|
|
function firstNonEmptyText(...candidates) {
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate !== 'string') continue;
|
|
const trimmed = candidate.trim();
|
|
if (trimmed.length > 0) return candidate;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function latestVersionText(versions) {
|
|
if (!Array.isArray(versions) || !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 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({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
|
|
const source = raw && typeof raw === 'object' ? raw : {};
|
|
return {
|
|
authorLogin,
|
|
authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '',
|
|
blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
|
|
blockHash: target?.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="thread-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="thread-blockchain-details-text">Текст записи</label>
|
|
<textarea class="input" id="thread-blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
|
|
<pre class="blockchain-raw-block" id="thread-blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
|
|
<div class="form-actions-grid">
|
|
<button class="secondary-btn" id="thread-blockchain-details-copy" type="button">Скопировать</button>
|
|
<button class="secondary-btn" id="thread-blockchain-details-raw" type="button">Показать сырой блок</button>
|
|
</div>
|
|
<button class="secondary-btn" id="thread-blockchain-details-close" type="button">Закрыть</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => {
|
|
root.innerHTML = '';
|
|
});
|
|
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
|
|
await copyTextToClipboard(rawText);
|
|
showToast('Данные блокчейна скопированы');
|
|
});
|
|
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
|
|
const rawEl = root.querySelector('#thread-blockchain-raw-block');
|
|
if (!rawEl) return;
|
|
rawEl.hidden = !rawEl.hidden;
|
|
});
|
|
}
|
|
|
|
function resolveNodeText(node) {
|
|
return firstNonEmptyText(
|
|
latestVersionText(node?.versions),
|
|
node?.text,
|
|
node?.message,
|
|
node?.body,
|
|
);
|
|
}
|
|
|
|
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="thread-reply-modal">
|
|
<div class="modal-card stack">
|
|
<h3 class="modal-title">${title}</h3>
|
|
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
|
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
|
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
|
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
|
<div class="form-actions-grid">
|
|
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
|
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const textEl = root.querySelector('#thread-reply-text');
|
|
const attachmentsEl = root.querySelector('#thread-reply-attachments');
|
|
const errorEl = root.querySelector('#thread-reply-error');
|
|
const submitEl = root.querySelector('#thread-reply-submit');
|
|
const attachments = [];
|
|
let inFlight = false;
|
|
|
|
const setBusy = (busy) => {
|
|
inFlight = !!busy;
|
|
submitEl.disabled = inFlight;
|
|
if (textEl) textEl.disabled = inFlight;
|
|
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
|
|
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
|
};
|
|
|
|
const close = () => {
|
|
root.innerHTML = '';
|
|
};
|
|
|
|
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
|
root.querySelector('#thread-reply-submit')?.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('#thread-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, () => root.querySelector('#thread-reply-submit')?.click());
|
|
|
|
if (textEl) textEl.focus();
|
|
}
|
|
|
|
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
|
const root = document.getElementById('modal-root');
|
|
const options = (Array.isArray(channels) ? channels : [])
|
|
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
|
.map((item, index) => {
|
|
const owner = String(item?.ownerLogin || '').trim();
|
|
const name = String(item?.channelName || '').trim();
|
|
const label = `${owner || 'my'}/${name || 'channel'}`;
|
|
return `<option value="${index}">${label}</option>`;
|
|
})
|
|
.join('');
|
|
|
|
root.innerHTML = `
|
|
<div class="modal" id="thread-repost-modal">
|
|
<div class="modal-card stack">
|
|
<h3 class="modal-title">Репост</h3>
|
|
<label class="meta-muted" for="thread-repost-channel-select">Канал</label>
|
|
<select id="thread-repost-channel-select" class="input">${options}</select>
|
|
<label class="meta-muted" for="thread-repost-comment">Комментарий</label>
|
|
<textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
|
|
<div class="meta-muted inline-error" id="thread-repost-error"></div>
|
|
<div class="form-actions-grid">
|
|
<button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button>
|
|
<button class="primary-btn" id="thread-repost-submit" type="button">Опубликовать репост</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const selectEl = root.querySelector('#thread-repost-channel-select');
|
|
const textEl = root.querySelector('#thread-repost-comment');
|
|
const errorEl = root.querySelector('#thread-repost-error');
|
|
const submitEl = root.querySelector('#thread-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('#thread-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 openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
|
const root = document.getElementById('modal-root');
|
|
const rows = Array.isArray(versions) ? versions : [];
|
|
root.innerHTML = `
|
|
<div class="modal" id="thread-history-modal">
|
|
<div class="modal-card stack">
|
|
<h3 class="modal-title">${title}</h3>
|
|
<div class="stack" id="thread-history-list"></div>
|
|
<div class="form-actions-grid">
|
|
<button class="secondary-btn" id="thread-history-close" type="button">Закрыть</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const list = root.querySelector('#thread-history-list');
|
|
if (list) {
|
|
rows.forEach((item, index) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'card stack';
|
|
const ts = Number(item?.createdAtMs || 0);
|
|
const text = String(item?.text || '').trim() || 'удалено';
|
|
row.innerHTML = `
|
|
<strong>Версия ${index + 1}</strong>
|
|
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
|
|
<p class="channel-message-body">${text}</p>
|
|
`;
|
|
list.append(row);
|
|
});
|
|
}
|
|
|
|
root.querySelector('#thread-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="thread-edit-modal">
|
|
<div class="modal-card stack">
|
|
<h3 class="modal-title">Редактировать сообщение</h3>
|
|
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
|
|
<div class="meta-muted inline-error" id="thread-edit-error"></div>
|
|
<div class="form-actions-grid">
|
|
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
|
|
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
|
|
</div>
|
|
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const textEl = root.querySelector('#thread-edit-text');
|
|
const errorEl = root.querySelector('#thread-edit-error');
|
|
if (textEl) textEl.value = String(initialText || '');
|
|
|
|
const close = () => {
|
|
root.innerHTML = '';
|
|
};
|
|
|
|
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
|
|
root.querySelector('#thread-edit-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('#thread-edit-delete')?.addEventListener('click', async () => {
|
|
try {
|
|
await onDelete();
|
|
close();
|
|
} catch (error) {
|
|
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
|
}
|
|
});
|
|
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
|
|
if (textEl) textEl.focus();
|
|
}
|
|
|
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
|
const card = document.createElement('article');
|
|
card.className = 'card stack thread-node-card channel-message-card';
|
|
|
|
const author = node?.authorLogin || 'автор';
|
|
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
|
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
|
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
|
const likes = Number(node?.likesCount || 0);
|
|
const replies = Number(node?.repliesCount || 0);
|
|
const ratings = Number(node?.ratingsCount || 0);
|
|
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
|
const msgSubType = Number(node?.msgSubType || 0);
|
|
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
|
|
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
|
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
|
|
const parsedText = parseMessageAttachments(text);
|
|
if (isRating) card.classList.add('is-rating');
|
|
card.classList.add('is-counters-visible');
|
|
|
|
const headingText = String(heading || '').trim();
|
|
if (headingText) {
|
|
const headingEl = document.createElement('strong');
|
|
headingEl.className = 'thread-node-heading';
|
|
headingEl.textContent = headingText;
|
|
card.append(headingEl);
|
|
}
|
|
|
|
const authorTile = document.createElement('button');
|
|
authorTile.type = 'button';
|
|
authorTile.className = 'channel-message-author-tile';
|
|
const headRow = document.createElement('div');
|
|
headRow.className = 'channel-message-head-row';
|
|
|
|
const avatar = createThreadAvatar(author);
|
|
|
|
const authorBlock = document.createElement('div');
|
|
authorBlock.className = 'channel-message-author';
|
|
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 = author;
|
|
const numberEl = document.createElement('span');
|
|
numberEl.className = 'author-line-num';
|
|
numberEl.textContent = `· #${localNumber}`;
|
|
titleMain.append(loginEl, numberEl);
|
|
title.append(titleMain);
|
|
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: `История #${localNumber}`,
|
|
versions,
|
|
});
|
|
});
|
|
title.append(editedMarker);
|
|
}
|
|
const timestamp = document.createElement('div');
|
|
timestamp.className = 'channel-message-time';
|
|
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
|
authorBlock.append(title, timestamp);
|
|
authorTile.append(avatar, authorBlock);
|
|
headRow.append(authorTile);
|
|
const typeMeta = getChannelMessageTypeMeta(node?.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 handlers?.onStatusAction === 'function') {
|
|
typeButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
animatePress(event.currentTarget);
|
|
handlers.onStatusAction(node);
|
|
});
|
|
} else {
|
|
typeButton.classList.add('is-static');
|
|
typeButton.disabled = true;
|
|
}
|
|
headRow.append(typeButton);
|
|
}
|
|
|
|
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
|
|
|
|
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 = `Удалённое сообщение от ${author}`;
|
|
deleted.title = 'Открыть историю изменений';
|
|
deleted.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
openMessageHistoryModal({
|
|
title: `История #${localNumber}`,
|
|
versions,
|
|
});
|
|
});
|
|
card.append(deleted);
|
|
return card;
|
|
} else {
|
|
card.append(headRow);
|
|
if (parsedText.attachments.length > 0) {
|
|
card.append(createAttachmentCarouselElement(parsedText.attachments, {
|
|
gateway: state.entrySettings.arweaveServer,
|
|
messageTimestampMs: node?.createdAtMs,
|
|
}));
|
|
}
|
|
if (isRating) {
|
|
const ratingBadge = document.createElement('span');
|
|
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
|
|
ratingBadge.textContent = 'Оценка';
|
|
card.append(ratingBadge);
|
|
}
|
|
const body = document.createElement('p');
|
|
body.className = 'channel-message-body';
|
|
body.textContent = parsedText.text;
|
|
card.append(body);
|
|
}
|
|
|
|
const target = buildTargetFromNode(node);
|
|
const refKey = messageRefKey(target);
|
|
if (!target || !handlers) return card;
|
|
|
|
if (refKey) card.dataset.messageKey = refKey;
|
|
|
|
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
|
|
|
const actionKey = makeReactionActionKey(target);
|
|
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
|
|
|
const isLiked = getMessageReactionState(target) === 'liked';
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'thread-node-actions channel-message-actions';
|
|
|
|
const likeButton = document.createElement('button');
|
|
likeButton.type = 'button';
|
|
likeButton.className = 'channel-action-item thread-like-btn';
|
|
if (isLiked) likeButton.classList.add('is-liked');
|
|
likeButton.innerHTML = `
|
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
|
<span class="channel-action-counter">${likes}</span>
|
|
`;
|
|
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, 'Лайк...');
|
|
try {
|
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
|
} catch (error) {
|
|
logThreadRuntimeError('like_click', error, {
|
|
action: isLiked ? 'unlike' : 'like',
|
|
targetBlockchainName: target?.blockchainName || '',
|
|
targetBlockNumber: target?.blockNumber,
|
|
});
|
|
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
|
|
}
|
|
});
|
|
|
|
const replyButton = document.createElement('button');
|
|
replyButton.type = 'button';
|
|
replyButton.className = 'channel-action-item thread-reply-btn';
|
|
replyButton.innerHTML = `
|
|
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
|
<span class="channel-action-label">Ответить</span>
|
|
<span class="channel-action-counter">${replies}</span>
|
|
`;
|
|
setActionTitle(replyButton, 'Ответить');
|
|
replyButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
animatePress(event.currentTarget);
|
|
openReplyModal({
|
|
navigate: handlers.navigate,
|
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
|
});
|
|
});
|
|
const ratingButton = document.createElement('button');
|
|
ratingButton.type = 'button';
|
|
ratingButton.className = 'channel-action-item thread-rating-btn';
|
|
ratingButton.innerHTML = `
|
|
<span class="channel-action-icon" aria-hidden="true">★</span>
|
|
<span class="channel-action-label">Оценка</span>
|
|
<span class="channel-action-counter">${ratings}</span>
|
|
`;
|
|
setActionTitle(ratingButton, 'Оценка');
|
|
ratingButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
animatePress(event.currentTarget);
|
|
openReplyModal({
|
|
navigate: handlers.navigate,
|
|
mode: 'rating',
|
|
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
|
});
|
|
});
|
|
|
|
const shareButton = document.createElement('button');
|
|
shareButton.type = 'button';
|
|
shareButton.className = 'channel-action-item thread-share-btn';
|
|
shareButton.innerHTML = `
|
|
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
|
<span class="channel-action-label">Отправить</span>
|
|
`;
|
|
setActionTitle(shareButton, 'Отправить');
|
|
shareButton.addEventListener('click', async (event) => {
|
|
event.stopPropagation();
|
|
animatePress(event.currentTarget);
|
|
await handlers.onShare(target);
|
|
});
|
|
|
|
// Репосты временно отключены до будущей реализации.
|
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
|
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
|
if (repostTarget) {
|
|
const originalButton = document.createElement('button');
|
|
originalButton.type = 'button';
|
|
originalButton.className = 'channel-action-item';
|
|
originalButton.innerHTML = `
|
|
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
|
<span class="channel-action-label">Оригинал</span>
|
|
`;
|
|
setActionTitle(originalButton, 'Оригинал');
|
|
originalButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
|
if (!ok) return;
|
|
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
|
|
if (!ownerLogin) return;
|
|
handlers.navigate(makeShineMessageRoute({
|
|
ownerLogin,
|
|
messageBlockchainName: repostTarget.blockchainName,
|
|
messageBlockNumber: repostTarget.blockNumber,
|
|
}));
|
|
});
|
|
actions.append(originalButton);
|
|
}
|
|
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({
|
|
target,
|
|
authorLogin: author,
|
|
timestampMs: node?.createdAtMs,
|
|
text,
|
|
raw: node,
|
|
localNumber,
|
|
msgSubType,
|
|
}));
|
|
});
|
|
actions.append(detailsButton);
|
|
if (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(text || '').trim() === 'удалено' ? '' : parsedText.text,
|
|
allowEmptyText: parsedText.attachments.length > 0,
|
|
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
|
|
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
|
});
|
|
});
|
|
actions.append(editButton);
|
|
}
|
|
card.append(actions);
|
|
authorTile.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
const login = String(node?.authorLogin || '').trim();
|
|
if (!login) return;
|
|
handlers.navigate(makeProfileRoute(login));
|
|
});
|
|
card.addEventListener('click', () => {
|
|
handlers.onOpenThread(target);
|
|
});
|
|
return card;
|
|
}
|
|
|
|
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'stack';
|
|
|
|
const normalized = Array.isArray(items) ? items : [];
|
|
normalized.forEach((branch, index) => {
|
|
try {
|
|
const nodeNumber = nextNumber();
|
|
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
|
|
row.classList.add('thread-node-level');
|
|
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
|
wrap.append(row);
|
|
|
|
if (Array.isArray(branch?.children) && branch.children.length) {
|
|
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
|
}
|
|
} catch (error) {
|
|
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
|
}
|
|
});
|
|
|
|
return wrap;
|
|
}
|
|
|
|
function applyPendingScroll(screen, routeKey) {
|
|
const target = pendingThreadScroll.get(routeKey);
|
|
if (!target) return;
|
|
|
|
const doScroll = () => {
|
|
if (target === '__LAST_REPLY__') {
|
|
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
|
const last = cards[cards.length - 1];
|
|
if (last) {
|
|
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
pendingThreadScroll.delete(routeKey);
|
|
return;
|
|
}
|
|
|
|
const node = screen.querySelector(`[data-message-key="${target}"]`);
|
|
if (node) {
|
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
pendingThreadScroll.delete(routeKey);
|
|
}
|
|
};
|
|
|
|
setTimeout(doScroll, 20);
|
|
}
|
|
|
|
function renderSkeleton(screen) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'stack';
|
|
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
|
|
screen.append(wrap);
|
|
return wrap;
|
|
}
|
|
|
|
export function render({ navigate, route, chrome }) {
|
|
const selector = parseThreadSelector(route);
|
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
|
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
|
let activeResolvedChannelLabel = channelDisplayName;
|
|
|
|
const screen = document.createElement('section');
|
|
screen.className = 'stack channels-screen channels-screen--thread';
|
|
const appScreen = document.getElementById('app-screen');
|
|
appScreen?.classList.add('channels-scroll-clean');
|
|
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
|
|
|
const header = renderHeader({
|
|
title: '',
|
|
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
|
rightActions: [],
|
|
});
|
|
header.classList.add('channel-thread-topbar');
|
|
const headerLeft = header.querySelector('.header-left');
|
|
let threadHeaderButton = null;
|
|
if (headerLeft) {
|
|
const channelsListButton = document.createElement('button');
|
|
channelsListButton.type = 'button';
|
|
channelsListButton.className = 'icon-btn';
|
|
channelsListButton.textContent = '↑';
|
|
channelsListButton.title = 'К списку каналов';
|
|
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
|
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
|
headerLeft.append(channelsListButton);
|
|
|
|
threadHeaderButton = document.createElement('button');
|
|
threadHeaderButton.type = 'button';
|
|
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
|
threadHeaderButton.textContent = 'Тред в канале: ...';
|
|
threadHeaderButton.disabled = true;
|
|
headerLeft.append(threadHeaderButton);
|
|
}
|
|
chrome?.setTopbar(header);
|
|
|
|
const statusBox = document.createElement('div');
|
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
|
statusBox.style.display = 'none';
|
|
|
|
const rerender = () => {
|
|
try {
|
|
const current = document.querySelector('section.channels-screen--thread');
|
|
if (!current) return;
|
|
const next = render({ navigate, route });
|
|
current.cleanup?.();
|
|
current.replaceWith(next);
|
|
} catch (error) {
|
|
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
|
}
|
|
};
|
|
|
|
const showStatus = (message) => {
|
|
if (!message) {
|
|
statusBox.style.display = 'none';
|
|
statusBox.textContent = '';
|
|
return;
|
|
}
|
|
statusBox.textContent = message;
|
|
statusBox.style.display = '';
|
|
};
|
|
|
|
const requireSigningSession = () => {
|
|
const login = state.session.login;
|
|
const storagePwd = state.session.storagePwdInMemory;
|
|
if (!login || !storagePwd) {
|
|
state.authReturnHash = window.location.pathname || '/channels';
|
|
navigate('login-view');
|
|
throw new Error('Для этого действия нужно войти');
|
|
}
|
|
return { login, storagePwd };
|
|
};
|
|
|
|
const handlers = {
|
|
navigate,
|
|
onToggleLike: async (target, action) => {
|
|
const actionKey = makeReactionActionKey(target);
|
|
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
|
if (pendingReactionActions.has(actionKey)) return;
|
|
|
|
const previousReaction = getMessageReactionState(target);
|
|
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
|
|
|
|
pendingReactionActions.add(actionKey);
|
|
try {
|
|
const { login, storagePwd } = requireSigningSession();
|
|
if (action === 'unlike') {
|
|
await authService.addBlockUnlike({ login, storagePwd, message: target });
|
|
} else {
|
|
await authService.addBlockLike({ login, storagePwd, message: target });
|
|
}
|
|
|
|
setMessageReactionState(target, nextReaction);
|
|
softHaptic(10);
|
|
rerender();
|
|
} catch (error) {
|
|
setMessageReactionState(target, previousReaction || 'unliked');
|
|
rerender();
|
|
throw error;
|
|
} finally {
|
|
pendingReactionActions.delete(actionKey);
|
|
}
|
|
},
|
|
onReply: async (target, textValue) => {
|
|
const { login, storagePwd } = requireSigningSession();
|
|
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
|
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
|
softHaptic(15);
|
|
showToast('Ответ отправлен');
|
|
showStatus('');
|
|
rerender();
|
|
},
|
|
onRating: async (target, textValue) => {
|
|
const { login, storagePwd } = requireSigningSession();
|
|
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
|
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
|
softHaptic(15);
|
|
showToast('Оценка отправлена');
|
|
showStatus('');
|
|
rerender();
|
|
},
|
|
onRepost: async (target) => {
|
|
const { login, storagePwd } = requireSigningSession();
|
|
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
|
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
|
|
.map((row) => {
|
|
const selectorRow = {
|
|
ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(),
|
|
channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber),
|
|
channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash),
|
|
};
|
|
if (!selectorRow.ownerBlockchainName || !Number.isFinite(selectorRow.channelRootBlockNumber) || selectorRow.channelRootBlockNumber < 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
ownerLogin: String(row?.channel?.ownerLogin || '').trim(),
|
|
channelName: String(row?.channel?.channelName || '').trim(),
|
|
channelTypeCode: Number(row?.channel?.channelTypeCode ?? 1),
|
|
selector: selectorRow,
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.filter((item) => Number(item.channelTypeCode) !== 0 && String(item.channelName || '').trim().toLowerCase() !== 'stories');
|
|
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
|
|
|
|
openRepostModal({
|
|
navigate,
|
|
channels,
|
|
onSubmit: async ({ channel, text }) => {
|
|
await authService.addBlockRepost({
|
|
login,
|
|
storagePwd,
|
|
channel,
|
|
message: target,
|
|
text,
|
|
});
|
|
softHaptic(12);
|
|
showToast('Репост опубликован');
|
|
showStatus('');
|
|
},
|
|
});
|
|
},
|
|
onShare: async (target) => {
|
|
try {
|
|
const routePath = buildThreadRouteFromTarget(target, selector);
|
|
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
|
const result = await shareOrCopyLink({
|
|
title: 'SHiNE · Тред',
|
|
text: 'Сообщение из треда SHiNE',
|
|
url: buildAbsoluteRouteUrl(routePath),
|
|
});
|
|
if (result === 'copied') showToast('Ссылка скопирована');
|
|
if (result === 'shared') showToast('Ссылка передана');
|
|
if (result === 'copied' || result === 'shared') softHaptic(10);
|
|
} catch (error) {
|
|
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
|
}
|
|
},
|
|
onOpenThread: (target) => {
|
|
const routePath = buildThreadRouteFromTarget(target, selector);
|
|
if (!routePath) {
|
|
showStatus('Не удалось определить путь до треда.');
|
|
return;
|
|
}
|
|
navigate(routePath);
|
|
},
|
|
onActionError: (error, action) => {
|
|
const fallback = action === 'unlike'
|
|
? 'Не удалось убрать лайк.'
|
|
: action === 'repost'
|
|
? 'Не удалось сделать репост.'
|
|
: 'Не удалось поставить лайк.';
|
|
showStatus(toUserMessage(error, fallback));
|
|
},
|
|
onEdit: async (target, textValue, meta = {}) => {
|
|
const { login, storagePwd } = requireSigningSession();
|
|
await authService.addBlockEditMessage({
|
|
login,
|
|
storagePwd,
|
|
message: target,
|
|
text: textValue,
|
|
isChannelPost: meta?.isChannelPost === true,
|
|
channel: selector?.channel || null,
|
|
});
|
|
softHaptic(12);
|
|
showToast('Сообщение обновлено');
|
|
showStatus('');
|
|
rerender();
|
|
},
|
|
};
|
|
|
|
screen.append(statusBox);
|
|
|
|
if (!selector) {
|
|
const invalid = document.createElement('div');
|
|
invalid.className = 'card meta-muted';
|
|
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
|
screen.append(invalid);
|
|
screen.cleanup = () => {
|
|
scrollToBottomControl.cleanup();
|
|
appScreen?.classList.remove('channels-scroll-clean');
|
|
};
|
|
return screen;
|
|
}
|
|
|
|
const skeleton = renderSkeleton(screen);
|
|
|
|
(async () => {
|
|
try {
|
|
let resolvedMessage = selector.message;
|
|
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
|
|
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
|
const allRows = [
|
|
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
|
|
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
|
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
|
|
];
|
|
const ownerRaw = String(selector.short.ownerBlockchainName || '').trim();
|
|
const ownerNormalized = ownerRaw.toLowerCase();
|
|
const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw);
|
|
const channelNameNormalized = String(selector.short.channelName || '').trim().toLowerCase();
|
|
let channel = allRows.find((item) => (
|
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
));
|
|
if (!channel) {
|
|
channel = allRows.find((item) => (
|
|
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerNormalized
|
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
));
|
|
}
|
|
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
|
|
try {
|
|
const ownerUser = await authService.getUser(ownerRaw);
|
|
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
|
if (ownerBch) {
|
|
channel = allRows.find((item) => (
|
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
));
|
|
}
|
|
} catch {
|
|
// ignore fallback lookup errors
|
|
}
|
|
}
|
|
if (!channel && ownerLoginFromBch) {
|
|
try {
|
|
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
|
|
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
|
channel = ownerRows.find((item) => (
|
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
));
|
|
} catch {
|
|
// ignore owner feed lookup errors
|
|
}
|
|
}
|
|
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
|
|
const rootNo = Number(channel?.channel?.channelRoot?.blockNumber);
|
|
const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash);
|
|
if (!ownerBch || !Number.isFinite(rootNo) || !Number.isFinite(resolvedMessage?.blockNumber)) {
|
|
throw new Error('Канал или сообщение не найдено.');
|
|
}
|
|
selector.channel = {
|
|
ownerBlockchainName: ownerBch,
|
|
channelRootBlockNumber: rootNo,
|
|
channelRootBlockHash: rootHash,
|
|
};
|
|
|
|
resolvedMessage = {
|
|
blockchainName: ownerBch,
|
|
blockNumber: resolvedMessage.blockNumber,
|
|
blockHash: normalizeMessageHash(resolvedMessage?.blockHash),
|
|
};
|
|
}
|
|
|
|
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
|
skeleton.remove();
|
|
|
|
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
|
const focus = payload?.focus || null;
|
|
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
|
|
|
const focusHash = normalizeMessageHash(focus?.messageRef?.blockHash);
|
|
if (focusHash && selector?.message) {
|
|
selector.message.blockHash = focusHash;
|
|
}
|
|
|
|
if ((!selector?.channel?.ownerBlockchainName || selector?.channel?.channelRootBlockNumber == null) && payload) {
|
|
const context = extractChannelContextFromThreadPayload(payload);
|
|
if (context) {
|
|
selector.channel = {
|
|
ownerBlockchainName: context.ownerBlockchainName,
|
|
channelRootBlockNumber: context.channelRootBlockNumber,
|
|
channelRootBlockHash: normalizeRouteHash(context.channelRootBlockHash),
|
|
};
|
|
}
|
|
}
|
|
|
|
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
|
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
|
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
|
}
|
|
activeResolvedChannelLabel = resolvedChannelLabel;
|
|
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
|
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
|
if (threadHeaderButton) {
|
|
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
|
|
threadHeaderButton.disabled = false;
|
|
threadHeaderButton.onclick = (event) => {
|
|
event.preventDefault();
|
|
animatePress(event.currentTarget);
|
|
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
|
|
if (routeToChannel) navigate(routeToChannel);
|
|
else navigate('channels-list');
|
|
};
|
|
}
|
|
|
|
let seq = 0;
|
|
const nextNumber = () => {
|
|
seq += 1;
|
|
return seq;
|
|
};
|
|
|
|
let ancestorsWrap = null;
|
|
if (ancestors.length) {
|
|
ancestorsWrap = document.createElement('div');
|
|
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
|
ancestors.forEach((node, index) => {
|
|
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
|
|
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
|
|
});
|
|
}
|
|
|
|
let focusWrap = null;
|
|
if (focus) {
|
|
focusWrap = document.createElement('div');
|
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
|
const focusTitle = document.createElement('h3');
|
|
focusTitle.className = 'section-title';
|
|
focusTitle.textContent = 'Текущее сообщение';
|
|
focusWrap.append(focusTitle);
|
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
|
}
|
|
|
|
const descendantsWrap = document.createElement('div');
|
|
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
|
const descendantsTitle = document.createElement('h3');
|
|
descendantsTitle.className = 'section-title';
|
|
descendantsTitle.textContent = 'Ответы и оценки';
|
|
descendantsWrap.append(descendantsTitle);
|
|
|
|
if (descendants.length) {
|
|
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
|
} else {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'card meta-muted';
|
|
empty.textContent = 'Ответов и оценок пока нет.';
|
|
descendantsWrap.append(empty);
|
|
}
|
|
|
|
if (ancestorsWrap) {
|
|
screen.append(ancestorsWrap);
|
|
const divider = document.createElement('div');
|
|
divider.className = 'thread-history-divider';
|
|
screen.append(divider);
|
|
}
|
|
|
|
if (focusWrap) screen.append(focusWrap);
|
|
screen.append(descendantsWrap);
|
|
|
|
applyPendingScroll(screen, routeKey);
|
|
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
|
if (!hasPendingScroll && focusWrap) {
|
|
setTimeout(() => {
|
|
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
}, 20);
|
|
}
|
|
} catch (error) {
|
|
skeleton.remove();
|
|
const failed = document.createElement('div');
|
|
failed.className = 'card meta-muted';
|
|
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
|
screen.append(failed);
|
|
}
|
|
})();
|
|
|
|
screen.cleanup = () => {
|
|
scrollToBottomControl.cleanup();
|
|
appScreen?.classList.remove('channels-scroll-clean');
|
|
};
|
|
|
|
return screen;
|
|
}
|