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 = `
+
-
-
+
+
+
${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)}` : ''}
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+ —
+
+
+ Баланс
+ —
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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;
+ }
+}