diff --git a/VERSION.properties b/VERSION.properties index d54533b7..93e1fe2e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.16 +client.version=1.12.17 server.version=1.10.7 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index bca3b962..f8dad777 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -102,6 +102,7 @@ import * as userRelationManageView from './pages/user-relation-manage-view.js'; import * as channelsList from './pages/channels-list.js?v=202608221218'; import * as channelView from './pages/channel-view.js'; import * as channelAboutView from './pages/channel-about-view.js'; +import * as channelDonateView from './pages/channel-donate-view.js'; import * as channelThreadView from './pages/channel-thread-view.js'; import * as addChannelView from './pages/add-channel-view.js'; import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; @@ -170,6 +171,7 @@ const routes = { 'channels-list': channelsList, 'channel-view': channelView, 'channel-about-view': channelAboutView, + 'channel-donate-view': channelDonateView, 'channel-thread-view': channelThreadView, 'add-channel-view': addChannelView, 'add-personal-public-chat-view': addPersonalPublicChatView, diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index 47ef5670..0197cc56 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -11,7 +11,7 @@ import { markArweaveAttachmentPlaced, } from '../components/arweave-attachment-manager.js'; import { renderAvatar } from '../components/avatar-image.js'; -import { makeShineChannelRoute } from '../services/shine-routes.js'; +import { makeShineChannelShortRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' }; @@ -54,7 +54,7 @@ function renderAvatarPreview(slot, avatar, title) { } function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) { - const route = makeShineChannelRoute({ ownerBlockchainName, channelName }); + const route = makeShineChannelShortRoute({ ownerBlockchainName, channelName }); if (!route) return ''; try { return new URL(`/${route}`, window.location.origin).toString(); diff --git a/shine-UI/js/pages/channel-about-view.js b/shine-UI/js/pages/channel-about-view.js index bbaa231a..b7849265 100644 --- a/shine-UI/js/pages/channel-about-view.js +++ b/shine-UI/js/pages/channel-about-view.js @@ -1,11 +1,27 @@ import { createTopBar } from '../components/topbar.js'; -import { authService, state } from '../state.js'; +import { renderAvatar } from '../components/avatar-image.js'; +import { authService, setChannelsFeed, state } from '../state.js'; import { navigateBack } from '../router.js'; -import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.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: 'О канале' }; +export const pageMeta = { id: 'channel-about-view', title: 'Описание канала' }; function escapeHtml(text) { return String(text || '') @@ -22,49 +38,384 @@ function normalizeHash(hash) { } function toSafeInt(value) { + if (value === '' || value == null) return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } -function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) { - const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase(); - const rootNo = Number(channelRootBlockNumber); - const rootHash = normalizeHash(channelRootBlockHash); - const rows = Object.values(state.channelsIndex || {}); - return rows.find((row) => ( - String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch - && Number(row?.channel?.channelRoot?.blockNumber) === rootNo - && normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash - )) || null; -} - function buildChannelLink(route) { if (!route) return ''; - const url = new URL(window.location.href); - url.pathname = `/${String(route).replace(/^\/+/, '')}`; - url.hash = ''; - return url.toString(); + 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'; } -export function render({ navigate, route, chrome }) { - const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim(); - const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber); - const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash); - const channelRoute = makeShineChannelRootRoute({ +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, - channelRootBlockHash, + 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: 'О канале', + title: '', back: { label: '←', onClick: () => { @@ -72,7 +423,7 @@ export function render({ navigate, route, chrome }) { navigateBack(); return; } - if (channelRoute) navigate(channelRoute); + navigate('channels-list'); }, ariaLabel: 'Назад', title: 'Назад', @@ -82,97 +433,210 @@ export function render({ navigate, route, chrome }) { const card = document.createElement('div'); card.className = 'card stack channel-about-card'; - card.innerHTML = ` -
-
Загрузка данных канала…
-
- `; - + const content = document.createElement('div'); + content.className = 'stack channel-about-content'; + content.innerHTML = '
Загрузка данных канала…
'; + card.append(content); screen.append(card); - const renderContent = (channel) => { + 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 ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').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 aboutRoute = makeShineChannelRootRoute({ - ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, - channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber, - channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash, + const shortRoute = makeShineChannelShortRoute({ + ownerLogin, + ownerBlockchainName: selector?.ownerBlockchainName, + channelName, }); - const channelLinkRoute = makeShineChannelShortRoute({ - ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, - channelName: channel?.channelName || '', + const donateRoute = makeShineChannelDonateRoute({ + ownerBlockchainName: selector?.ownerBlockchainName, + channelRootBlockNumber: selector?.channelRootBlockNumber, + channelRootBlockHash: selector?.channelRootBlockHash, }); - const channelLink = buildChannelLink(channelLinkRoute); - const changedAtMs = Number(channel?.metaUpdatedAtMs || 0); - const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—'; - const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен'; + const serverLink = buildChannelLink(shortRoute); + const ownerName = ownerDisplayName(ownerProfile, ownerLogin); - const content = card.querySelector('#channel-about-content'); - if (!content) return; content.innerHTML = ` -
- -
-
- Дата${escapeHtml(changedAtLabel)} - Владелец${escapeHtml(ownerName)} - Подписчиков${escapeHtml(statsText(subscribersCount))} - Системное имя${escapeHtml(String(channel?.channelName || '').trim() || 'channel')} - Название${escapeHtml(cleanName)} - Описание${escapeHtml(description || 'Описание не задано.')} - Аватар${escapeHtml(avatarState)} - Ссылка${escapeHtml(channelLink)} -
-
- - +
+
+

${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 (!channelLinkRoute) return; - navigate(channelLinkRoute); + if (shortRoute) navigate(shortRoute); }); content.querySelector('#channel-about-copy')?.addEventListener('click', async () => { - if (!channelLink) return; + if (!serverLink) return; try { - await navigator.clipboard.writeText(channelLink); + 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 cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash); - if (cached?.channel) { - renderContent({ - ...cached.channel, - subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0, - }); - return screen; - } - - void (async () => { + const refresh = async () => { + const seq = ++refreshSeq; + content.innerHTML = '
Загрузка данных канала…
'; try { - const payload = await authService.getChannelMessages({ - ownerBlockchainName, - channelRootBlockNumber, - channelRootBlockHash, - }, 1, 'asc', String(state.session.login || '').trim()); - renderContent(payload?.channel || {}); - } catch (error) { - const content = card.querySelector('#channel-about-content'); - if (content) { - content.innerHTML = ` -
Не удалось загрузить данные канала.
-
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
- `; - } - } - })(); + 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; } diff --git a/shine-UI/js/pages/channel-donate-view.js b/shine-UI/js/pages/channel-donate-view.js new file mode 100644 index 00000000..b6f5a2f0 --- /dev/null +++ b/shine-UI/js/pages/channel-donate-view.js @@ -0,0 +1,399 @@ +import { createTopBar } from '../components/topbar.js'; +import { authService, state } from '../state.js'; +import { navigateBack } from '../router.js'; +import { extractLoginFromBlockchainName } from '../services/shine-routes.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-donate-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; +} + +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; + } +} + +export function render({ navigate, route, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack channels-screen channels-screen--channel-donate'; + + 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-donate-card'; + const content = document.createElement('div'); + content.className = 'stack channel-donate-content'; + content.innerHTML = '
Загрузка данных канала…
'; + card.append(content); + screen.append(card); + + let disposed = false; + let refreshSeq = 0; + let busy = false; + const walletCache = new Map(); + + const cleanupWallets = () => { + for (const wallet of walletCache.values()) { + try { + wallet?.keypair?.secretKey?.fill?.(0); + } catch { + // Секретные ключи живут только в памяти этой страницы. + } + } + walletCache.clear(); + }; + + const renderDonationForm = ({ channel, selector, ownerUser }) => { + const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim(); + const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); + const channelName = String(channel?.channelName || selector?.channelName || '').trim(); + 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); + + content.innerHTML = ` +
+

Донат автору

+ ${escapeHtml(channelTitle)} + ${escapeHtml(ownerLogin)}${channelName ? ` / ${escapeHtml(channelName)}` : ''} +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ Баланс + + +
+
+ +
+ +
+ + SOL +
+
+ +

+ + + `; + + const recipientSelect = content.querySelector('#channel-donate-recipient-key'); + const senderSelect = content.querySelector('#channel-donate-sender-key'); + const recipientAddressEl = content.querySelector('#channel-donate-recipient-address'); + const senderAddressEl = content.querySelector('#channel-donate-sender-address'); + const balanceEl = content.querySelector('#channel-donate-balance'); + const amountEl = content.querySelector('#channel-donate-amount'); + const statusEl = content.querySelector('#channel-donate-status'); + const resultEl = content.querySelector('#channel-donate-result'); + const submitEl = content.querySelector('#channel-donate-submit'); + const refreshEl = content.querySelector('#channel-donate-refresh'); + + 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 || disposed) return; + setBusy(true); + setStatus('Загрузка баланса…'); + if (balanceEl) balanceEl.textContent = '—'; + try { + const wallet = await resolveSenderWallet(); + if (disposed) return; + if (senderAddressEl) senderAddressEl.textContent = wallet.address; + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: wallet.address, + }); + if (disposed) return; + if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + setStatus(''); + } catch (error) { + if (disposed) return; + if (senderAddressEl) senderAddressEl.textContent = '—'; + setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); + } finally { + if (!disposed) 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()); + + submitEl?.addEventListener('click', async () => { + if (busy || disposed) 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 (disposed) 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 (!disposed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + } catch (error) { + if (!disposed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error'); + } finally { + if (!disposed) setBusy(false); + } + }); + + updateRecipientAddress(); + if (!recipientChoices.length) { + setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error'); + } else { + void refreshBalance(); + } + }; + + const refresh = async () => { + const seq = ++refreshSeq; + cleanupWallets(); + 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 ownerUser = await authService.getUser(ownerLogin); + if (disposed || seq !== refreshSeq) return; + if (!ownerUser?.exists) throw new Error('Владелец канала не найден.'); + + renderDonationForm({ channel, selector, ownerUser }); + } catch (error) { + if (disposed || seq !== refreshSeq) return; + content.innerHTML = ` +
Не удалось открыть донат автору.
+
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
+ + `; + content.querySelector('#channel-donate-retry')?.addEventListener('click', () => void refresh()); + } + }; + + screen.refresh = refresh; + screen.cleanup = () => { + if (disposed) return; + disposed = true; + refreshSeq += 1; + cleanupWallets(); + }; + + void refresh(); + return screen; +} diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 0201d3b0..31eb013d 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -31,6 +31,7 @@ import { makeProfileRoute, makeShineMessageRoute, makeShineChannelAboutRoute, + makeShineChannelDonateRoute, } from '../services/shine-routes.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -669,7 +670,7 @@ function openChannelMetaDetailsModal({ function openAboutChannelModal(channel, options = {}) { openChannelMetaDetailsModal({ - title: 'О канале', + title: 'Описание канала', channel, canEdit: options.canEdit === true, onEdit: options.onEdit, @@ -1595,9 +1596,13 @@ async function loadFromApi(route, channelId) { try { const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500); const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; + const ownerLoginNormalized = ownerLoginForLookup.toLowerCase(); channel = ownerRows.find((item) => ( - String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized - && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + && ( + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized + || String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerLoginNormalized + ) )); } catch { // ignore owner feed lookup failures @@ -2535,7 +2540,7 @@ export function render({ navigate, route, chrome }) { actions: [ { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, { - label: '⋯', + label: '⋮', title: 'Действия канала', ariaLabel: 'Открыть меню канала', className: 'channel-header-more-btn', @@ -2544,14 +2549,38 @@ export function render({ navigate, route, chrome }) { items: () => { const apiData = activeChannelData; if (!apiData) return []; - const aboutRoute = makeShineChannelAboutRoute({ + const routeArgs = { ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '', - }); - const items = [ - { label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } }, - ]; + }; + const aboutRoute = makeShineChannelAboutRoute(routeArgs); + const donateRoute = makeShineChannelDonateRoute(routeArgs); + const items = []; + if (apiData?.isOwnChannel && !apiData?.isDiary) { + items.push({ + label: 'Добавить сообщение', + action: () => { + openAddMessageModal({ + channelName: apiData?.channel?.name || '', + navigate, + isActive: () => !disposed, + onSubmit: async ({ text: bodyText, msgSubType }) => { + try { + await onAddPost(bodyText, msgSubType); + showStatus(''); + } catch (error) { + throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); + } + }, + }); + }, + }); + } + items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } }); + if (!apiData?.isOwnChannel) { + items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } }); + } if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) { if (apiData?.isSubscribed) { items.push({ diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 35c7d02f..1baff6e5 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -28,6 +28,7 @@ const PRETTY_PATHS = new Map([ ['add-personal-public-chat-view', 'channels/new-public-chat'], ['channel-view', 'channel'], ['channel-about-view', 'channel/about'], + ['channel-donate-view', 'channel/donate'], ['channel-thread-view', 'thread'], ['network-view', 'network'], ['notifications-view', 'notifications'], @@ -180,9 +181,9 @@ export function parseRouteFromPath(pathname = '') { const channelName = decodePart(segments[1] || ''); const sub = decodePart(segments[2] || '').toLowerCase(); if (ownerBlockchainName && channelName) { - if (sub === 'about') { + if (sub === 'about' || sub === 'donate') { return { - pageId: 'channel-about-view', + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', params: { ownerBlockchainName, channelRootBlockNumber: '', @@ -270,16 +271,19 @@ export function parseRouteFromPath(pathname = '') { } if (pageId === 'channel') { - if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') { - return { - pageId: 'channel-about-view', - params: { - ownerBlockchainName: decodePart(segments[1]), - channelRootBlockNumber: segments[2] || '', - channelRootBlockHash: segments[3] || '', - channelId: '', - }, - }; + if (segments.length >= 5) { + const sub = decodePart(segments[4] || '').toLowerCase(); + if (sub === 'about' || sub === 'donate') { + return { + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } } if (segments.length >= 4) { return { @@ -358,6 +362,18 @@ export function parseRouteFromPath(pathname = '') { return { pageId: 'remote-addblock-session-view', params: {} }; } + if (pageId === 'channel-donate-view' || pageId === 'channel-about-view') { + return { + pageId, + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } + if (pageId === 'channel-view') { if (segments.length >= 4) { return { @@ -399,6 +415,32 @@ export function parseRouteFromPath(pathname = '') { return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; } + // Публичная короткая ссылка канала не содержит внутренний номер блокчейна: + // //. Старый /-001/ обрабатывается выше. + if (segments.length === 2 || (segments.length === 3 && ['about', 'donate'].includes(decodePart(segments[2] || '').toLowerCase()))) { + const ownerBlockchainName = decodePart(segments[0] || ''); + const channelName = decodePart(segments[1] || ''); + if (ownerBlockchainName && channelName) { + if (segments.length === 3) { + const sub = decodePart(segments[2] || '').toLowerCase(); + return { + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', + params: { + ownerBlockchainName, + channelRootBlockNumber: '', + channelRootBlockHash: '', + channelId: '', + channelName, + }, + }; + } + return { + pageId: 'channel-view', + params: { ownerBlockchainName, channelName, channelId: '' }, + }; + } + } + return { pageId, params: {} }; } @@ -467,7 +509,7 @@ export function resolveToolbarActive(pageId) { pageId === 'solana-users-init-view' ) return 'profile-view'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; - if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; + if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'user') return 'messages-list'; return 'profile-view'; } diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js index 27df45d2..fe5d107f 100644 --- a/shine-UI/js/services/shine-routes.js +++ b/shine-UI/js/services/shine-routes.js @@ -64,11 +64,16 @@ export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRo return base ? `${base}/about` : ''; } -export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) { - const ownerBch = String(ownerBlockchainName || '').trim(); +export function makeShineChannelDonateRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash }); + return base ? `${base}/donate` : ''; +} + +export function makeShineChannelShortRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '' }) { + const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName); const chName = String(channelName || '').trim(); - if (!ownerBch || !chName) return ''; - return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; + if (!cleanOwnerLogin || !chName) return ''; + return `${encodeRoutePart(cleanOwnerLogin)}/${encodeRoutePart(chName)}`; } export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) { diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index ba6ac7ae..6842f10d 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -1,4 +1,5 @@ import { extractClientKey32FromStoredValue } from './client-key-utils.js'; +import { base64ToBytes } from './crypto-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; @@ -45,6 +46,17 @@ function encodeBase58(bytesLike) { return out; } + +export function solanaAddressFromPublicKeyBase64(publicKeyB64) { + const clean = String(publicKeyB64 || '').trim(); + if (!clean) return ''; + const bytes = base64ToBytes(clean); + if (bytes.length !== 32) { + throw new Error('Публичный ключ Solana должен содержать 32 байта'); + } + return encodeBase58(bytes); +} + function normalizeEndpoint(url) { const raw = String(url || '').trim(); if (!raw) return DEFAULT_SOLANA_ENDPOINT; @@ -110,6 +122,17 @@ async function keypairFromStoredSecret(storedSecret) { } } + +export async function getSolanaWalletFromStoredSecret(storedSecret) { + const clean = String(storedSecret || '').trim(); + if (!clean) throw new Error('Не передан приватный ключ'); + const keypair = await keypairFromStoredSecret(clean); + return { + address: keypair.publicKey.toBase58(), + keypair, + }; +} + export async function createRandomSolanaWallet() { const solana = await loadSolanaLib(); const keypair = solana.Keypair.generate(); diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index 8ddf33a2..9d9287aa 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -826,3 +826,524 @@ margin: 4px 0; padding: 10px; } + +/* ===== Channel description ===== */ +.channels-screen--channel-about { + padding-bottom: calc(22px + env(safe-area-inset-bottom)); +} + +.channel-about-card { + padding: 0; + overflow: hidden; +} + +.channel-about-content { + gap: 0; +} + +.channel-about-hero { + display: grid; + justify-items: center; + gap: 6px; + padding: 26px 20px 22px; + text-align: center; +} + +.channel-about-avatar-slot { + margin-bottom: 8px; +} + +.channel-about-avatar.channel-profile-avatar { + box-shadow: 0 12px 32px rgba(3, 8, 18, 0.34); +} + +.channel-about-title { + margin: 0; + color: rgba(255, 255, 255, 0.97); + font-size: clamp(24px, 7vw, 32px); + line-height: 1.12; + font-weight: 760; + overflow-wrap: anywhere; +} + +.channel-about-technical { + color: rgba(196, 210, 238, 0.72); + font-size: 14px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.channel-about-subscribers { + color: rgba(170, 190, 226, 0.68); + font-size: 14px; + line-height: 1.35; +} + +.channel-about-section { + display: grid; + gap: 10px; + padding: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.065); +} + +.channel-about-section h3 { + margin: 0; + color: rgba(255, 218, 135, 0.92); + font-size: 14px; + font-weight: 700; +} + +.channel-about-description { + margin: 0; + color: rgba(235, 241, 255, 0.92); + font-size: 16px; + line-height: 1.58; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.channel-about-owner-link { + display: grid; + gap: 3px; + justify-items: start; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.channel-about-owner-link strong { + color: rgba(255, 255, 255, 0.96); + font-size: 16px; + line-height: 1.3; +} + +.channel-about-owner-link span { + color: rgba(176, 198, 234, 0.72); + font-size: 13px; + line-height: 1.3; +} + +.channel-about-owner-link:hover strong, +.channel-about-owner-link:focus-visible strong { + color: #f4dca6; +} + +.channel-about-owner-link:focus-visible { + outline: 2px solid rgba(244, 220, 166, 0.52); + outline-offset: 5px; + border-radius: 8px; +} + +.channel-about-support-btn { + width: 100%; + min-height: 44px; + margin-top: 2px; +} + +.channel-about-link-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 48px; + padding: 10px 10px 10px 12px; + border: 1px solid rgba(137, 168, 220, 0.14); + border-radius: 12px; + background: rgba(7, 15, 29, 0.38); +} + + +.channel-about-link-box a, +.channel-about-link-box span { + color: rgba(169, 215, 255, 0.95); + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; +} + + +.channel-about-copy-btn { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 19px; +} + +.channel-about-open-btn, +.channel-about-subscription-btn { + margin: 18px 20px 0; + min-height: 48px; +} + +.channel-about-subscription-btn { + margin-top: 12px; + margin-bottom: 20px; +} + +.channel-about-subscription-btn.is-unsubscribe { + border: 1px solid rgba(255, 91, 105, 0.68); + background: rgba(181, 43, 56, 0.92); + color: #fff; +} + +.channel-about-subscription-btn.is-unsubscribe:hover, +.channel-about-subscription-btn.is-unsubscribe:focus-visible { + background: rgba(205, 49, 63, 0.98); +} + +.channel-about-open-btn:last-child { + margin-bottom: 20px; +} + +/* ===== Channel unsubscribe confirmation ===== */ +#channel-unsubscribe-confirm-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#channel-unsubscribe-confirm-modal .channel-unsubscribe-confirm-card { + width: min(100%, 390px); + gap: 14px; + padding: 20px; +} + +.channel-unsubscribe-confirm-card .modal-title { + margin: 0; +} + +.channel-unsubscribe-confirm-text { + margin: 0; + line-height: 1.5; +} + +.channel-unsubscribe-confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.channel-unsubscribe-confirm-actions button { + min-height: 44px; +} + +.channel-unsubscribe-confirm-yes { + border: 1px solid rgba(255, 91, 105, 0.68); + background: rgba(181, 43, 56, 0.92); + color: #fff; +} + +.channel-unsubscribe-confirm-yes:hover, +.channel-unsubscribe-confirm-yes:focus-visible { + background: rgba(205, 49, 63, 0.98); +} + +/* ===== Channel support transfer ===== */ +#channel-support-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#channel-support-modal .channel-support-modal-card { + width: min(100%, 430px); + max-height: min(88vh, 720px); + overflow-y: auto; + gap: 9px; + padding: 20px; +} + +.channel-support-modal-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + margin-bottom: 4px; +} + +.channel-support-modal-head .modal-title { + margin: 0 0 3px; +} + +.channel-support-subtitle { + margin: 0; + overflow-wrap: anywhere; +} + +.channel-support-close { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 24px; + line-height: 1; +} + +.channel-support-address { + min-height: 36px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(5, 11, 23, 0.44); + color: rgba(186, 215, 255, 0.8); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; +} + +.channel-support-balance-row strong { + color: rgba(255, 255, 255, 0.94); + text-align: right; +} + +.channel-support-refresh { + min-height: 36px; + padding-block: 6px; +} + +.channel-support-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.channel-support-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; +} + +.channel-support-status { + min-height: 18px; + margin: 2px 0 0; + color: rgba(192, 207, 236, 0.76); +} + +.channel-support-status.is-error { + color: #ff9fa8; +} + +.channel-support-result { + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid rgba(130, 227, 164, 0.18); + border-radius: 12px; + background: rgba(44, 105, 67, 0.15); +} + +.channel-support-result[hidden] { + display: none; +} + +.channel-support-result strong { + color: #c9f2d8; +} + +.channel-support-result span, +.channel-support-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-submit { + min-height: 48px; + margin-top: 4px; +} + +@media (max-width: 420px) { + .channel-about-section { + padding-inline: 16px; + } + + .channel-about-open-btn, + .channel-about-subscription-btn { + margin-inline: 16px; + } + + .channel-support-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-support-balance-row .meta-muted { + grid-column: 1 / -1; + } +} + +/* ===== Channel donation view ===== */ +.channels-screen--channel-donate { + padding-bottom: calc(24px + env(safe-area-inset-bottom)); +} + +.channel-donate-card { + width: min(100%, 680px); + margin-inline: auto; + padding: 0; + overflow: hidden; +} + +.channel-donate-content { + gap: 0; +} + +.channel-donate-hero { + display: grid; + justify-items: center; + gap: 5px; + padding: 28px 20px 24px; + text-align: center; +} + +.channel-donate-hero h2 { + margin: 0 0 4px; + color: rgba(255, 255, 255, 0.97); + font-size: clamp(24px, 7vw, 31px); + line-height: 1.12; + font-weight: 760; +} + +.channel-donate-hero strong { + color: rgba(255, 230, 169, 0.94); + font-size: 17px; + overflow-wrap: anywhere; +} + +.channel-donate-hero span { + color: rgba(176, 198, 234, 0.72); + font-size: 13px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.channel-donate-section { + display: grid; + gap: 9px; + padding: 18px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.065); +} + +.channel-donate-address { + min-height: 38px; + padding: 9px 10px; + border-radius: 10px; + background: rgba(5, 11, 23, 0.44); + color: rgba(186, 215, 255, 0.82); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-donate-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; + margin-top: 3px; +} + +.channel-donate-balance-row strong { + color: rgba(255, 255, 255, 0.94); + text-align: right; +} + +.channel-donate-refresh { + min-height: 36px; + padding-block: 6px; +} + +.channel-donate-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.channel-donate-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; +} + +.channel-donate-status { + min-height: 18px; + margin: 14px 20px 0; + color: rgba(192, 207, 236, 0.76); +} + +.channel-donate-status.is-error { + color: #ff9fa8; +} + +.channel-donate-result { + display: grid; + gap: 6px; + margin: 12px 20px 0; + padding: 12px 14px; + border: 1px solid rgba(130, 227, 164, 0.18); + border-radius: 12px; + background: rgba(44, 105, 67, 0.15); +} + +.channel-donate-result[hidden] { + display: none; +} + +.channel-donate-result strong { + color: #c9f2d8; +} + +.channel-donate-result span, +.channel-donate-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-donate-submit { + min-height: 50px; + margin: 16px 20px 22px; +} + +@media (max-width: 420px) { + .channel-donate-section { + padding-inline: 16px; + } + + .channel-donate-status, + .channel-donate-result, + .channel-donate-submit { + margin-inline: 16px; + } + + .channel-donate-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-donate-balance-row .meta-muted { + grid-column: 1 / -1; + } +}