import { iconHtml } from '../components/ui-icon.js'; import { createTopBar } from '../components/topbar.js'; import { renderAvatar } from '../components/avatar-image.js'; import { authService, setChannelsFeed, state } from '../state.js'; import { navigateBack, getPreviousTrackedPath } 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, getWalletFromStoredBlockchainKey, 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, 'blockchainKey', 'blockchain-key', 'Blockchain 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 = 'blockchain-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('Для перевода нужно войти в Сиянии.'); const wallet = await getWalletFromStoredBlockchainKey({ 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); }); }); } function subscribersWord(count) { const n = Math.abs(Number(count) || 0) % 100; const last = n % 10; if (n > 10 && n < 20) return 'подписчиков'; if (last === 1) return 'подписчик'; if (last >= 2 && last <= 4) return 'подписчика'; return 'подписчиков'; } 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 shortLink = String(serverLink || '').replace(/^https?:\/\//, ''); const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, ''); const ownerName = ownerDisplayName(ownerProfile, ownerLogin); const subscriptionTile = isOwnChannel ? '' : ` `; content.innerHTML = `

${escapeHtml(cleanName)}

@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}
${cameFromChannel ? '' : ``} ${subscriptionTile}
Описание

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

`; const channelAvatar = renderAvatar({ initials: cleanName.slice(0, 1).toUpperCase() || 'К', avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null, size: 'xl', className: 'channel-about-avatar channel-profile-avatar', title: cleanName, alt: 'Аватар канала', }); content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar); content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({ initials: ownerName.slice(0, 1).toUpperCase() || '?', size: 'md', className: 'channel-about-owner-avatar-img', alt: '', })); 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); }); const copyLink = async () => { if (!serverLink) return; try { await navigator.clipboard.writeText(serverLink); showToast('Ссылка скопирована'); } catch (error) { showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' }); } }; content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink); 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; }