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 = ` `; 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 = ` Перевод выполнен ✓ ${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)} ${escapeHtml(String(result.signature || ''))} `; } 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 = ` `; 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 = '
Загрузка данных канала…
'; 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 = `

${escapeHtml(cleanName)}

${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}
${escapeHtml(statsText(subscribersCount))} подписчиков

О канале

${escapeHtml(description || 'Описание не задано.')}

Владелец канала

Ссылка на этом сервере

${isOwnChannel ? '' : ` `} `; 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 = '
Загрузка данных канала…
'; 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 = `
Не удалось загрузить данные канала.
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
`; 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; }