SHA256
643 lines
26 KiB
JavaScript
643 lines
26 KiB
JavaScript
import { createTopBar } from '../components/topbar.js';
|
||
import { renderAvatar } from '../components/avatar-image.js';
|
||
import { authService, setChannelsFeed, state } from '../state.js';
|
||
import { navigateBack } from '../router.js';
|
||
import {
|
||
extractLoginFromBlockchainName,
|
||
makeProfileRoute,
|
||
makeShineChannelShortRoute,
|
||
makeShineChannelDonateRoute,
|
||
} from '../services/shine-routes.js';
|
||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||
import {
|
||
formatSol,
|
||
getBalanceSol,
|
||
getSolanaWalletFromStoredSecret,
|
||
getWalletFromStoredClientKey,
|
||
getWalletFromStoredRootKey,
|
||
solanaAddressFromPublicKeyBase64,
|
||
transferSol,
|
||
} from '../services/solana-wallet-service.js';
|
||
import { showToast } from '../services/channels-ux.js';
|
||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||
|
||
export const pageMeta = { id: 'channel-about-view', title: 'Описание канала' };
|
||
|
||
function escapeHtml(text) {
|
||
return String(text || '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function normalizeHash(hash) {
|
||
const normalized = String(hash || '').trim().toLowerCase();
|
||
return normalized || '0';
|
||
}
|
||
|
||
function toSafeInt(value) {
|
||
if (value === '' || value == null) return null;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function buildChannelLink(route) {
|
||
if (!route) return '';
|
||
try {
|
||
return new URL(`/${String(route).replace(/^\/+/, '')}`, window.location.origin).toString();
|
||
} catch {
|
||
return `${window.location.origin}/${String(route).replace(/^\/+/, '')}`;
|
||
}
|
||
}
|
||
|
||
function statsText(value) {
|
||
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
|
||
}
|
||
|
||
function profileField(snapshot, key) {
|
||
const row = Array.isArray(snapshot?.fields)
|
||
? snapshot.fields.find((item) => String(item?.key || '') === key)
|
||
: null;
|
||
return String(row?.value || '').trim();
|
||
}
|
||
|
||
function ownerDisplayName(snapshot, ownerLogin) {
|
||
const firstName = profileField(snapshot, 'first_name');
|
||
const lastName = profileField(snapshot, 'last_name');
|
||
return [firstName, lastName].filter(Boolean).join(' ').trim() || String(ownerLogin || '').trim() || 'Владелец канала';
|
||
}
|
||
|
||
function isSelectorMatch(row, selector) {
|
||
return Boolean(
|
||
row?.channel
|
||
&& String(row.channel.ownerBlockchainName || '') === String(selector?.ownerBlockchainName || '')
|
||
&& Number(row.channel.channelRoot?.blockNumber) === Number(selector?.channelRootBlockNumber)
|
||
&& normalizeHash(row.channel.channelRoot?.blockHash) === normalizeHash(selector?.channelRootBlockHash)
|
||
);
|
||
}
|
||
|
||
async function resolveChannelSelector(route) {
|
||
const ownerRef = String(route?.params?.ownerBlockchainName || '').trim();
|
||
const rootNo = toSafeInt(route?.params?.channelRootBlockNumber);
|
||
const rootHash = normalizeHash(route?.params?.channelRootBlockHash);
|
||
const channelName = String(route?.params?.channelName || '').trim();
|
||
|
||
if (ownerRef && rootNo != null) {
|
||
return {
|
||
ownerBlockchainName: ownerRef,
|
||
channelRootBlockNumber: rootNo,
|
||
channelRootBlockHash: rootHash,
|
||
channelName,
|
||
};
|
||
}
|
||
|
||
if (!ownerRef || !channelName) {
|
||
throw new Error('Не удалось определить канал из адреса страницы.');
|
||
}
|
||
|
||
const ownerLogin = extractLoginFromBlockchainName(ownerRef);
|
||
const ownerUser = await authService.getUser(ownerLogin);
|
||
if (!ownerUser?.exists) throw new Error('Владелец канала не найден.');
|
||
|
||
const ownerBlockchainName = String(ownerUser.blockchainName || ownerRef).trim();
|
||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLogin, 500);
|
||
const ownedRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||
const row = ownedRows.find((item) => (
|
||
String(item?.channel?.channelName || '').trim().toLowerCase() === channelName.toLowerCase()
|
||
&& String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBlockchainName.toLowerCase()
|
||
));
|
||
if (!row?.channel?.channelRoot) throw new Error('Канал не найден.');
|
||
|
||
return {
|
||
ownerBlockchainName,
|
||
channelRootBlockNumber: Number(row.channel.channelRoot.blockNumber),
|
||
channelRootBlockHash: normalizeHash(row.channel.channelRoot.blockHash),
|
||
channelName,
|
||
};
|
||
}
|
||
|
||
function publicKeyChoice(ownerUser, field, id, label) {
|
||
const publicKeyB64 = String(ownerUser?.[field] || '').trim();
|
||
if (!publicKeyB64) return null;
|
||
try {
|
||
return {
|
||
id,
|
||
label,
|
||
address: solanaAddressFromPublicKeyBase64(publicKeyB64),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||
const root = document.getElementById('modal-root');
|
||
if (!root) return;
|
||
|
||
const recipientChoices = [
|
||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||
].filter(Boolean);
|
||
|
||
root.innerHTML = `
|
||
<div class="modal" id="channel-support-modal">
|
||
<div class="modal-card stack channel-support-modal-card">
|
||
<div class="channel-support-modal-head">
|
||
<div>
|
||
<h3 class="modal-title">Донат автору</h3>
|
||
<p class="meta-muted channel-support-subtitle">${escapeHtml(channelTitle || 'Канал')} · @${escapeHtml(ownerLogin)}</p>
|
||
</div>
|
||
<button class="icon-btn channel-support-close" id="channel-support-close" type="button" aria-label="Закрыть" title="Закрыть">×</button>
|
||
</div>
|
||
|
||
<label class="field-label" for="channel-support-blockchain">Блокчейн</label>
|
||
<select class="select" id="channel-support-blockchain">
|
||
<option value="solana">Solana</option>
|
||
</select>
|
||
|
||
<label class="field-label" for="channel-support-recipient-key">Счёт получателя</label>
|
||
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
||
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
||
</select>
|
||
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||
|
||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||
<select class="select" id="channel-support-sender-key">
|
||
<option value="client-key">Client key</option>
|
||
<option value="root-key">Root key</option>
|
||
</select>
|
||
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||
|
||
<div class="channel-support-balance-row">
|
||
<span class="meta-muted">Баланс</span>
|
||
<strong id="channel-support-balance">—</strong>
|
||
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
|
||
</div>
|
||
|
||
<label class="field-label" for="channel-support-amount">Сумма</label>
|
||
<div class="channel-support-amount-row">
|
||
<input class="input" id="channel-support-amount" type="number" min="0" step="0.000001" inputmode="decimal" placeholder="0.1" />
|
||
<span>SOL</span>
|
||
</div>
|
||
|
||
<p class="meta-muted inline-error channel-support-status" id="channel-support-status"></p>
|
||
<div class="channel-support-result" id="channel-support-result" hidden></div>
|
||
<button class="primary-btn channel-support-submit" id="channel-support-submit" type="button" ${recipientChoices.length ? '' : 'disabled'}>Перевести</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const modal = root.querySelector('#channel-support-modal');
|
||
const recipientSelect = root.querySelector('#channel-support-recipient-key');
|
||
const senderSelect = root.querySelector('#channel-support-sender-key');
|
||
const recipientAddressEl = root.querySelector('#channel-support-recipient-address');
|
||
const senderAddressEl = root.querySelector('#channel-support-sender-address');
|
||
const balanceEl = root.querySelector('#channel-support-balance');
|
||
const amountEl = root.querySelector('#channel-support-amount');
|
||
const statusEl = root.querySelector('#channel-support-status');
|
||
const resultEl = root.querySelector('#channel-support-result');
|
||
const submitEl = root.querySelector('#channel-support-submit');
|
||
const refreshEl = root.querySelector('#channel-support-refresh');
|
||
const walletCache = new Map();
|
||
let closed = false;
|
||
let busy = false;
|
||
|
||
const cleanupWallets = () => {
|
||
for (const wallet of walletCache.values()) {
|
||
try {
|
||
wallet?.keypair?.secretKey?.fill?.(0);
|
||
} catch {
|
||
// best effort: секрет существует только в памяти модального окна
|
||
}
|
||
}
|
||
walletCache.clear();
|
||
};
|
||
|
||
const close = () => {
|
||
if (closed) return;
|
||
closed = true;
|
||
cleanupWallets();
|
||
if (root.querySelector('#channel-support-modal') === modal) root.innerHTML = '';
|
||
};
|
||
|
||
const setBusy = (nextBusy) => {
|
||
busy = Boolean(nextBusy);
|
||
if (submitEl) submitEl.disabled = busy || recipientChoices.length === 0;
|
||
if (refreshEl) refreshEl.disabled = busy;
|
||
if (recipientSelect) recipientSelect.disabled = busy || recipientChoices.length === 0;
|
||
if (senderSelect) senderSelect.disabled = busy;
|
||
if (amountEl) amountEl.disabled = busy;
|
||
};
|
||
|
||
const setStatus = (message, kind = '') => {
|
||
if (!statusEl) return;
|
||
statusEl.textContent = String(message || '');
|
||
statusEl.classList.toggle('is-error', kind === 'error');
|
||
};
|
||
|
||
const selectedRecipient = () => recipientChoices.find((choice) => choice.id === String(recipientSelect?.value || '')) || recipientChoices[0] || null;
|
||
|
||
const updateRecipientAddress = () => {
|
||
const choice = selectedRecipient();
|
||
if (recipientAddressEl) recipientAddressEl.textContent = choice?.address || 'Публичный ключ получателя недоступен';
|
||
};
|
||
|
||
const resolveSenderWallet = async () => {
|
||
const keyId = String(senderSelect?.value || 'client-key');
|
||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||
|
||
const login = String(state.session.login || '').trim();
|
||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||
|
||
let wallet;
|
||
if (keyId === 'root-key') {
|
||
try {
|
||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||
} catch {
|
||
const password = window.prompt(
|
||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||
'',
|
||
);
|
||
if (password == null) throw new Error('Операция отменена.');
|
||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||
}
|
||
} else {
|
||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||
}
|
||
|
||
walletCache.set(keyId, wallet);
|
||
return wallet;
|
||
};
|
||
|
||
const refreshBalance = async () => {
|
||
if (busy || closed) return;
|
||
setBusy(true);
|
||
setStatus('Загрузка баланса…');
|
||
if (balanceEl) balanceEl.textContent = '—';
|
||
try {
|
||
const wallet = await resolveSenderWallet();
|
||
if (closed) return;
|
||
if (senderAddressEl) senderAddressEl.textContent = wallet.address;
|
||
const balance = await getBalanceSol({
|
||
endpoint: state.entrySettings.solanaServer,
|
||
address: wallet.address,
|
||
});
|
||
if (closed) return;
|
||
if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
|
||
setStatus('');
|
||
} catch (error) {
|
||
if (closed) return;
|
||
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
||
} finally {
|
||
if (!closed) setBusy(false);
|
||
}
|
||
};
|
||
|
||
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
||
senderSelect?.addEventListener('change', () => {
|
||
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||
if (balanceEl) balanceEl.textContent = '—';
|
||
setStatus('');
|
||
void refreshBalance();
|
||
});
|
||
refreshEl?.addEventListener('click', () => void refreshBalance());
|
||
root.querySelector('#channel-support-close')?.addEventListener('click', close);
|
||
modal?.addEventListener('pointerdown', (event) => {
|
||
if (event.target === modal) close();
|
||
});
|
||
|
||
submitEl?.addEventListener('click', async () => {
|
||
if (busy || closed) return;
|
||
const recipient = selectedRecipient();
|
||
if (!recipient?.address) {
|
||
setStatus('Не удалось определить счёт получателя.', 'error');
|
||
return;
|
||
}
|
||
const amount = Number(String(amountEl?.value || '').replace(',', '.'));
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
setStatus('Введите сумму перевода больше 0.', 'error');
|
||
return;
|
||
}
|
||
|
||
setBusy(true);
|
||
setStatus('Отправляем перевод…');
|
||
if (resultEl) {
|
||
resultEl.hidden = true;
|
||
resultEl.innerHTML = '';
|
||
}
|
||
try {
|
||
const wallet = await resolveSenderWallet();
|
||
const result = await transferSol({
|
||
endpoint: state.entrySettings.solanaServer,
|
||
fromKeypair: wallet.keypair,
|
||
toAddress: recipient.address,
|
||
amountSol: amount,
|
||
});
|
||
if (closed) return;
|
||
setStatus('');
|
||
if (resultEl) {
|
||
resultEl.hidden = false;
|
||
resultEl.innerHTML = `
|
||
<strong>Перевод выполнен ✓</strong>
|
||
<span>${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)}</span>
|
||
<code>${escapeHtml(String(result.signature || ''))}</code>
|
||
`;
|
||
}
|
||
showToast('Перевод отправлен');
|
||
const balance = await getBalanceSol({
|
||
endpoint: state.entrySettings.solanaServer,
|
||
address: wallet.address,
|
||
});
|
||
if (!closed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
|
||
} catch (error) {
|
||
if (!closed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error');
|
||
} finally {
|
||
if (!closed) setBusy(false);
|
||
}
|
||
});
|
||
|
||
updateRecipientAddress();
|
||
if (!recipientChoices.length) {
|
||
setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error');
|
||
} else {
|
||
void refreshBalance();
|
||
}
|
||
return close;
|
||
}
|
||
|
||
function confirmUnsubscribeModal({ channelTitle }) {
|
||
const root = document.getElementById('modal-root');
|
||
if (!root) return Promise.resolve(window.confirm('Отписаться от канала?'));
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
const finish = (value) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (root.querySelector('#channel-unsubscribe-confirm-modal')) root.innerHTML = '';
|
||
resolve(Boolean(value));
|
||
};
|
||
|
||
root.innerHTML = `
|
||
<div class="modal" id="channel-unsubscribe-confirm-modal">
|
||
<div class="modal-card stack channel-unsubscribe-confirm-card">
|
||
<h3 class="modal-title">Отписаться от канала?</h3>
|
||
<p class="meta-muted channel-unsubscribe-confirm-text">
|
||
Вы действительно хотите отписаться от «${escapeHtml(channelTitle || 'этого канала')}»?
|
||
</p>
|
||
<div class="channel-unsubscribe-confirm-actions">
|
||
<button class="secondary-btn" id="channel-unsubscribe-no" type="button">Нет</button>
|
||
<button class="destructive-btn channel-unsubscribe-confirm-yes" id="channel-unsubscribe-yes" type="button">Да</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const modal = root.querySelector('#channel-unsubscribe-confirm-modal');
|
||
root.querySelector('#channel-unsubscribe-no')?.addEventListener('click', () => finish(false));
|
||
root.querySelector('#channel-unsubscribe-yes')?.addEventListener('click', () => finish(true));
|
||
modal?.addEventListener('pointerdown', (event) => {
|
||
if (event.target === modal) finish(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
export function render({ navigate, route, chrome }) {
|
||
const screen = document.createElement('section');
|
||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||
|
||
const topbar = createTopBar({
|
||
title: '',
|
||
back: {
|
||
label: '←',
|
||
onClick: () => {
|
||
if (window.history.length > 1) {
|
||
navigateBack();
|
||
return;
|
||
}
|
||
navigate('channels-list');
|
||
},
|
||
ariaLabel: 'Назад',
|
||
title: 'Назад',
|
||
},
|
||
});
|
||
chrome?.setTopbar(topbar);
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'card stack channel-about-card';
|
||
const content = document.createElement('div');
|
||
content.className = 'stack channel-about-content';
|
||
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||
card.append(content);
|
||
screen.append(card);
|
||
|
||
let disposed = false;
|
||
let refreshSeq = 0;
|
||
let closeSupportModal = null;
|
||
|
||
const requireSigningSession = () => {
|
||
const login = String(state.session.login || '').trim();
|
||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||
if (!login || !storagePwd) {
|
||
state.authReturnHash = window.location.pathname || '/channels';
|
||
navigate('login-view');
|
||
throw new Error('Для этого действия нужно войти.');
|
||
}
|
||
return { login, storagePwd };
|
||
};
|
||
|
||
const renderContent = ({ channel, selector, ownerUser, ownerProfile, isOwnChannel, isSubscribed }) => {
|
||
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||
const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim();
|
||
const channelName = String(channel?.channelName || '').trim();
|
||
const description = String(channel?.channelDescription || channel?.description || '').trim();
|
||
const subscribersCount = Number(channel?.subscribersCount || 0);
|
||
const shortRoute = makeShineChannelShortRoute({
|
||
ownerLogin,
|
||
ownerBlockchainName: selector?.ownerBlockchainName,
|
||
channelName,
|
||
});
|
||
const donateRoute = makeShineChannelDonateRoute({
|
||
ownerBlockchainName: selector?.ownerBlockchainName,
|
||
channelRootBlockNumber: selector?.channelRootBlockNumber,
|
||
channelRootBlockHash: selector?.channelRootBlockHash,
|
||
});
|
||
const serverLink = buildChannelLink(shortRoute);
|
||
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
|
||
|
||
content.innerHTML = `
|
||
<div class="channel-about-hero">
|
||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
|
||
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
|
||
</div>
|
||
|
||
<section class="channel-about-section">
|
||
<h3>О канале</h3>
|
||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||
</section>
|
||
|
||
<section class="channel-about-section channel-about-owner-section">
|
||
<h3>Владелец канала</h3>
|
||
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||
<strong>${escapeHtml(ownerName)}</strong>
|
||
<span>@${escapeHtml(ownerLogin)}</span>
|
||
</button>
|
||
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
|
||
</section>
|
||
|
||
|
||
<section class="channel-about-section">
|
||
<h3>Ссылка на этом сервере</h3>
|
||
<div class="channel-about-link-box">
|
||
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
|
||
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
|
||
</div>
|
||
</section>
|
||
|
||
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
|
||
${isOwnChannel ? '' : `
|
||
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
|
||
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
|
||
</button>
|
||
`}
|
||
`;
|
||
|
||
if (String(channel?.avaAr || '').trim()) {
|
||
const avatar = renderAvatar({
|
||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||
size: 'xl',
|
||
className: 'channel-about-avatar channel-profile-avatar',
|
||
title: cleanName,
|
||
alt: 'Аватар канала',
|
||
});
|
||
avatar.style.setProperty('--channel-avatar-size', '112px');
|
||
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
|
||
} else {
|
||
content.querySelector('#channel-about-avatar-slot')?.remove();
|
||
}
|
||
|
||
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
|
||
const profileRoute = makeProfileRoute(ownerLogin);
|
||
if (profileRoute) navigate(profileRoute);
|
||
});
|
||
content.querySelector('#channel-about-support')?.addEventListener('click', () => {
|
||
if (donateRoute) navigate(donateRoute);
|
||
});
|
||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||
if (shortRoute) navigate(shortRoute);
|
||
});
|
||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||
if (!serverLink) return;
|
||
try {
|
||
await navigator.clipboard.writeText(serverLink);
|
||
showToast('Ссылка скопирована');
|
||
} catch (error) {
|
||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||
}
|
||
});
|
||
|
||
const subscriptionButton = content.querySelector('#channel-about-subscription');
|
||
subscriptionButton?.addEventListener('click', async () => {
|
||
if (subscriptionButton.disabled) return;
|
||
try {
|
||
const { login, storagePwd } = requireSigningSession();
|
||
if (isSubscribed) {
|
||
const confirmed = await confirmUnsubscribeModal({ channelTitle: cleanName });
|
||
if (!confirmed) return;
|
||
}
|
||
subscriptionButton.disabled = true;
|
||
await authService.addBlockFollowChannel({
|
||
login,
|
||
storagePwd,
|
||
targetBlockchainName: selector.ownerBlockchainName,
|
||
targetBlockNumber: selector.channelRootBlockNumber,
|
||
targetBlockHashHex: selector.channelRootBlockHash,
|
||
unfollow: isSubscribed,
|
||
});
|
||
if (disposed) return;
|
||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||
if (disposed) return;
|
||
setChannelsFeed(feed, state.channelsIndex);
|
||
showToast(isSubscribed ? 'Вы отписались от канала' : 'Подписка на канал выполнена');
|
||
void refresh();
|
||
} catch (error) {
|
||
if (!disposed) {
|
||
showToast(toUserMessage(error, isSubscribed ? 'Не удалось отписаться от канала.' : 'Не удалось подписаться на канал.'), { kind: 'error' });
|
||
subscriptionButton.disabled = false;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const refresh = async () => {
|
||
const seq = ++refreshSeq;
|
||
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||
try {
|
||
const selector = await resolveChannelSelector(route);
|
||
const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim());
|
||
if (disposed || seq !== refreshSeq) return;
|
||
|
||
const channel = payload?.channel || {};
|
||
const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim();
|
||
const currentLogin = String(state.session.login || '').trim();
|
||
const isOwnChannel = Boolean(ownerLogin && currentLogin && ownerLogin.toLowerCase() === currentLogin.toLowerCase());
|
||
|
||
const [ownerUserResult, ownerProfileResult, feedResult] = await Promise.allSettled([
|
||
authService.getUser(ownerLogin),
|
||
loadProfileSnapshot(ownerLogin),
|
||
currentLogin && !isOwnChannel ? authService.listSubscriptionsFeed(currentLogin, 1000) : Promise.resolve(null),
|
||
]);
|
||
if (disposed || seq !== refreshSeq) return;
|
||
|
||
const ownerUser = ownerUserResult.status === 'fulfilled' ? ownerUserResult.value : {};
|
||
const ownerProfile = ownerProfileResult.status === 'fulfilled' ? ownerProfileResult.value : null;
|
||
const feed = feedResult.status === 'fulfilled' ? feedResult.value : null;
|
||
if (feed) setChannelsFeed(feed, state.channelsIndex);
|
||
const followedRows = Array.isArray(feed?.followedChannels)
|
||
? feed.followedChannels
|
||
: (Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []);
|
||
const isSubscribed = !isOwnChannel && followedRows.some((row) => isSelectorMatch(row, selector));
|
||
|
||
renderContent({
|
||
channel,
|
||
selector,
|
||
ownerUser,
|
||
ownerProfile,
|
||
isOwnChannel,
|
||
isSubscribed,
|
||
});
|
||
} catch (error) {
|
||
if (disposed || seq !== refreshSeq) return;
|
||
content.innerHTML = `
|
||
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||
<button class="secondary-btn" id="channel-about-retry" type="button">Повторить</button>
|
||
`;
|
||
content.querySelector('#channel-about-retry')?.addEventListener('click', () => void refresh());
|
||
}
|
||
};
|
||
|
||
screen.refresh = refresh;
|
||
screen.cleanup = () => {
|
||
if (disposed) return;
|
||
disposed = true;
|
||
refreshSeq += 1;
|
||
closeSupportModal?.();
|
||
closeSupportModal = null;
|
||
};
|
||
|
||
void refresh();
|
||
return screen;
|
||
}
|