SHA256
Compare commits
6
Commits
1be1d56599
..
svyazi
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
23969121e5 | ||
|
|
4da2d7129a | ||
|
|
2be5ad0b7d | ||
|
|
f44ab75c64 | ||
|
|
7aa05dc609 | ||
|
|
7b81d140ec |
+5
@@ -28,6 +28,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -266,6 +267,8 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -374,6 +377,8 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.15
|
||||
server.version=1.10.6
|
||||
client.version=1.12.19
|
||||
server.version=1.10.7
|
||||
|
||||
+2
-1
@@ -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,
|
||||
@@ -216,7 +218,6 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
'chat-view',
|
||||
'channel-view',
|
||||
'channel-thread-view',
|
||||
'notifications-view',
|
||||
]);
|
||||
|
||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
|
||||
@@ -36,6 +36,7 @@ export function renderAvatar({
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
official = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
@@ -52,6 +53,15 @@ export function renderAvatar({
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
if (official) {
|
||||
const badge = document.createElement('img');
|
||||
badge.className = 'avatar-official-badge';
|
||||
badge.src = '/assets/shine-official-badge.svg?v=2';
|
||||
badge.alt = '';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
wrap.append(badge);
|
||||
}
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
if (!validateArweaveTxId(txId)) {
|
||||
return wrap;
|
||||
@@ -146,6 +156,7 @@ export function renderUserAvatar({
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
official = false,
|
||||
} = {}) {
|
||||
return renderAvatar({
|
||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||
@@ -155,5 +166,6 @@ export function renderUserAvatar({
|
||||
title,
|
||||
alt: 'Аватар',
|
||||
glow,
|
||||
official,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = `
|
||||
<div class="modal" id="channel-support-modal">
|
||||
<div class="modal-card stack channel-support-modal-card">
|
||||
<div class="channel-support-modal-head">
|
||||
<div>
|
||||
<h3 class="modal-title">Донат автору</h3>
|
||||
<p class="meta-muted channel-support-subtitle">${escapeHtml(channelTitle || 'Канал')} · @${escapeHtml(ownerLogin)}</p>
|
||||
</div>
|
||||
<button class="icon-btn channel-support-close" id="channel-support-close" type="button" aria-label="Закрыть" title="Закрыть">×</button>
|
||||
</div>
|
||||
|
||||
<label class="field-label" for="channel-support-blockchain">Блокчейн</label>
|
||||
<select class="select" id="channel-support-blockchain">
|
||||
<option value="solana">Solana</option>
|
||||
</select>
|
||||
|
||||
<label class="field-label" for="channel-support-recipient-key">Счёт получателя</label>
|
||||
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
||||
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
||||
</select>
|
||||
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||||
|
||||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-support-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||||
|
||||
<div class="channel-support-balance-row">
|
||||
<span class="meta-muted">Баланс</span>
|
||||
<strong id="channel-support-balance">—</strong>
|
||||
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
|
||||
<label class="field-label" for="channel-support-amount">Сумма</label>
|
||||
<div class="channel-support-amount-row">
|
||||
<input class="input" id="channel-support-amount" type="number" min="0" step="0.000001" inputmode="decimal" placeholder="0.1" />
|
||||
<span>SOL</span>
|
||||
</div>
|
||||
|
||||
<p class="meta-muted inline-error channel-support-status" id="channel-support-status"></p>
|
||||
<div class="channel-support-result" id="channel-support-result" hidden></div>
|
||||
<button class="primary-btn channel-support-submit" id="channel-support-submit" type="button" ${recipientChoices.length ? '' : 'disabled'}>Перевести</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = root.querySelector('#channel-support-modal');
|
||||
const recipientSelect = root.querySelector('#channel-support-recipient-key');
|
||||
const senderSelect = root.querySelector('#channel-support-sender-key');
|
||||
const recipientAddressEl = root.querySelector('#channel-support-recipient-address');
|
||||
const senderAddressEl = root.querySelector('#channel-support-sender-address');
|
||||
const balanceEl = root.querySelector('#channel-support-balance');
|
||||
const amountEl = root.querySelector('#channel-support-amount');
|
||||
const statusEl = root.querySelector('#channel-support-status');
|
||||
const resultEl = root.querySelector('#channel-support-result');
|
||||
const submitEl = root.querySelector('#channel-support-submit');
|
||||
const refreshEl = root.querySelector('#channel-support-refresh');
|
||||
const walletCache = new Map();
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const cleanupWallets = () => {
|
||||
for (const wallet of walletCache.values()) {
|
||||
try {
|
||||
wallet?.keypair?.secretKey?.fill?.(0);
|
||||
} catch {
|
||||
// best effort: секрет существует только в памяти модального окна
|
||||
}
|
||||
}
|
||||
walletCache.clear();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cleanupWallets();
|
||||
if (root.querySelector('#channel-support-modal') === modal) root.innerHTML = '';
|
||||
};
|
||||
|
||||
const setBusy = (nextBusy) => {
|
||||
busy = Boolean(nextBusy);
|
||||
if (submitEl) submitEl.disabled = busy || recipientChoices.length === 0;
|
||||
if (refreshEl) refreshEl.disabled = busy;
|
||||
if (recipientSelect) recipientSelect.disabled = busy || recipientChoices.length === 0;
|
||||
if (senderSelect) senderSelect.disabled = busy;
|
||||
if (amountEl) amountEl.disabled = busy;
|
||||
};
|
||||
|
||||
const setStatus = (message, kind = '') => {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = String(message || '');
|
||||
statusEl.classList.toggle('is-error', kind === 'error');
|
||||
};
|
||||
|
||||
const selectedRecipient = () => recipientChoices.find((choice) => choice.id === String(recipientSelect?.value || '')) || recipientChoices[0] || null;
|
||||
|
||||
const updateRecipientAddress = () => {
|
||||
const choice = selectedRecipient();
|
||||
if (recipientAddressEl) recipientAddressEl.textContent = choice?.address || 'Публичный ключ получателя недоступен';
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
try {
|
||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||||
} catch {
|
||||
const password = window.prompt(
|
||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена.');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||||
}
|
||||
} else {
|
||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||||
}
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
};
|
||||
|
||||
const refreshBalance = async () => {
|
||||
if (busy || closed) return;
|
||||
setBusy(true);
|
||||
setStatus('Загрузка баланса…');
|
||||
if (balanceEl) balanceEl.textContent = '—';
|
||||
try {
|
||||
const wallet = await resolveSenderWallet();
|
||||
if (closed) return;
|
||||
if (senderAddressEl) senderAddressEl.textContent = wallet.address;
|
||||
const balance = await getBalanceSol({
|
||||
endpoint: state.entrySettings.solanaServer,
|
||||
address: wallet.address,
|
||||
});
|
||||
if (closed) return;
|
||||
if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
|
||||
setStatus('');
|
||||
} catch (error) {
|
||||
if (closed) return;
|
||||
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
||||
} finally {
|
||||
if (!closed) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
||||
senderSelect?.addEventListener('change', () => {
|
||||
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||
if (balanceEl) balanceEl.textContent = '—';
|
||||
setStatus('');
|
||||
void refreshBalance();
|
||||
});
|
||||
refreshEl?.addEventListener('click', () => void refreshBalance());
|
||||
root.querySelector('#channel-support-close')?.addEventListener('click', close);
|
||||
modal?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === modal) close();
|
||||
});
|
||||
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (busy || closed) return;
|
||||
const recipient = selectedRecipient();
|
||||
if (!recipient?.address) {
|
||||
setStatus('Не удалось определить счёт получателя.', 'error');
|
||||
return;
|
||||
}
|
||||
const amount = Number(String(amountEl?.value || '').replace(',', '.'));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setStatus('Введите сумму перевода больше 0.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus('Отправляем перевод…');
|
||||
if (resultEl) {
|
||||
resultEl.hidden = true;
|
||||
resultEl.innerHTML = '';
|
||||
}
|
||||
try {
|
||||
const wallet = await resolveSenderWallet();
|
||||
const result = await transferSol({
|
||||
endpoint: state.entrySettings.solanaServer,
|
||||
fromKeypair: wallet.keypair,
|
||||
toAddress: recipient.address,
|
||||
amountSol: amount,
|
||||
});
|
||||
if (closed) return;
|
||||
setStatus('');
|
||||
if (resultEl) {
|
||||
resultEl.hidden = false;
|
||||
resultEl.innerHTML = `
|
||||
<strong>Перевод выполнен ✓</strong>
|
||||
<span>${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)}</span>
|
||||
<code>${escapeHtml(String(result.signature || ''))}</code>
|
||||
`;
|
||||
}
|
||||
showToast('Перевод отправлен');
|
||||
const balance = await getBalanceSol({
|
||||
endpoint: state.entrySettings.solanaServer,
|
||||
address: wallet.address,
|
||||
});
|
||||
if (!closed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
|
||||
} catch (error) {
|
||||
if (!closed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error');
|
||||
} finally {
|
||||
if (!closed) setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
updateRecipientAddress();
|
||||
if (!recipientChoices.length) {
|
||||
setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error');
|
||||
} else {
|
||||
void refreshBalance();
|
||||
}
|
||||
return close;
|
||||
}
|
||||
|
||||
function confirmUnsubscribeModal({ channelTitle }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return Promise.resolve(window.confirm('Отписаться от канала?'));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (root.querySelector('#channel-unsubscribe-confirm-modal')) root.innerHTML = '';
|
||||
resolve(Boolean(value));
|
||||
};
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-unsubscribe-confirm-modal">
|
||||
<div class="modal-card stack channel-unsubscribe-confirm-card">
|
||||
<h3 class="modal-title">Отписаться от канала?</h3>
|
||||
<p class="meta-muted channel-unsubscribe-confirm-text">
|
||||
Вы действительно хотите отписаться от «${escapeHtml(channelTitle || 'этого канала')}»?
|
||||
</p>
|
||||
<div class="channel-unsubscribe-confirm-actions">
|
||||
<button class="secondary-btn" id="channel-unsubscribe-no" type="button">Нет</button>
|
||||
<button class="destructive-btn channel-unsubscribe-confirm-yes" id="channel-unsubscribe-yes" type="button">Да</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = root.querySelector('#channel-unsubscribe-confirm-modal');
|
||||
root.querySelector('#channel-unsubscribe-no')?.addEventListener('click', () => finish(false));
|
||||
root.querySelector('#channel-unsubscribe-yes')?.addEventListener('click', () => finish(true));
|
||||
modal?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === modal) finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: 'О канале',
|
||||
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 = `
|
||||
<div class="stack" id="channel-about-content">
|
||||
<div class="meta-muted">Загрузка данных канала…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'stack channel-about-content';
|
||||
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||
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 = `
|
||||
<div class="channel-profile-modal-head">
|
||||
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
|
||||
</div>
|
||||
<div class="channel-meta-details-grid">
|
||||
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
||||
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
|
||||
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
|
||||
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
|
||||
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
|
||||
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
||||
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
|
||||
</div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
|
||||
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
|
||||
<div class="channel-about-hero">
|
||||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||||
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
|
||||
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>О канале</h3>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</section>
|
||||
|
||||
<section class="channel-about-section channel-about-owner-section">
|
||||
<h3>Владелец канала</h3>
|
||||
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>@${escapeHtml(ownerLogin)}</span>
|
||||
</button>
|
||||
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>Ссылка на этом сервере</h3>
|
||||
<div class="channel-about-link-box">
|
||||
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
|
||||
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
|
||||
${isOwnChannel ? '' : `
|
||||
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
|
||||
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
|
||||
</button>
|
||||
`}
|
||||
`;
|
||||
|
||||
if (String(channel?.avaAr || '').trim()) {
|
||||
const avatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
avatar.style.setProperty('--channel-avatar-size', '112px');
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
|
||||
} else {
|
||||
content.querySelector('#channel-about-avatar-slot')?.remove();
|
||||
}
|
||||
|
||||
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
|
||||
const profileRoute = makeProfileRoute(ownerLogin);
|
||||
if (profileRoute) navigate(profileRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-support')?.addEventListener('click', () => {
|
||||
if (donateRoute) navigate(donateRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||
if (!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 = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||
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 = `
|
||||
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
const selector = await resolveChannelSelector(route);
|
||||
const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim());
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
|
||||
const channel = payload?.channel || {};
|
||||
const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim();
|
||||
const currentLogin = String(state.session.login || '').trim();
|
||||
const isOwnChannel = Boolean(ownerLogin && currentLogin && ownerLogin.toLowerCase() === currentLogin.toLowerCase());
|
||||
|
||||
const [ownerUserResult, ownerProfileResult, feedResult] = await Promise.allSettled([
|
||||
authService.getUser(ownerLogin),
|
||||
loadProfileSnapshot(ownerLogin),
|
||||
currentLogin && !isOwnChannel ? authService.listSubscriptionsFeed(currentLogin, 1000) : Promise.resolve(null),
|
||||
]);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
|
||||
const ownerUser = ownerUserResult.status === 'fulfilled' ? ownerUserResult.value : {};
|
||||
const ownerProfile = ownerProfileResult.status === 'fulfilled' ? ownerProfileResult.value : null;
|
||||
const feed = feedResult.status === 'fulfilled' ? feedResult.value : null;
|
||||
if (feed) setChannelsFeed(feed, state.channelsIndex);
|
||||
const followedRows = Array.isArray(feed?.followedChannels)
|
||||
? feed.followedChannels
|
||||
: (Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []);
|
||||
const isSubscribed = !isOwnChannel && followedRows.some((row) => isSelectorMatch(row, selector));
|
||||
|
||||
renderContent({
|
||||
channel,
|
||||
selector,
|
||||
ownerUser,
|
||||
ownerProfile,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
});
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
content.innerHTML = `
|
||||
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||||
<button class="secondary-btn" id="channel-about-retry" type="button">Повторить</button>
|
||||
`;
|
||||
content.querySelector('#channel-about-retry')?.addEventListener('click', () => void refresh());
|
||||
}
|
||||
};
|
||||
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
closeSupportModal?.();
|
||||
closeSupportModal = null;
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -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 = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||
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 = `
|
||||
<div class="channel-donate-hero">
|
||||
<h2>Донат автору</h2>
|
||||
<strong>${escapeHtml(channelTitle)}</strong>
|
||||
<span>${escapeHtml(ownerLogin)}${channelName ? ` / ${escapeHtml(channelName)}` : ''}</span>
|
||||
</div>
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-blockchain">Блокчейн</label>
|
||||
<select class="select" id="channel-donate-blockchain">
|
||||
<option value="solana">Solana</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-recipient-key">Счёт получателя</label>
|
||||
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
||||
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
||||
</select>
|
||||
<div class="channel-donate-address" id="channel-donate-recipient-address">—</div>
|
||||
</section>
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-donate-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
|
||||
|
||||
<div class="channel-donate-balance-row">
|
||||
<span class="meta-muted">Баланс</span>
|
||||
<strong id="channel-donate-balance">—</strong>
|
||||
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-amount">Сумма</label>
|
||||
<div class="channel-donate-amount-row">
|
||||
<input class="input" id="channel-donate-amount" type="number" min="0" step="0.000001" inputmode="decimal" placeholder="0.1" />
|
||||
<span>SOL</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="meta-muted inline-error channel-donate-status" id="channel-donate-status"></p>
|
||||
<div class="channel-donate-result" id="channel-donate-result" hidden></div>
|
||||
<button class="primary-btn channel-donate-submit" id="channel-donate-submit" type="button" ${recipientChoices.length ? '' : 'disabled'}>Перевести</button>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<strong>Перевод выполнен ✓</strong>
|
||||
<span>${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)}</span>
|
||||
<code>${escapeHtml(String(result.signature || ''))}</code>
|
||||
`;
|
||||
}
|
||||
showToast('Перевод отправлен');
|
||||
|
||||
const balance = await getBalanceSol({
|
||||
endpoint: state.entrySettings.solanaServer,
|
||||
address: wallet.address,
|
||||
});
|
||||
if (!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 = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||
try {
|
||||
const selector = await resolveChannelSelector(route);
|
||||
const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim());
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
|
||||
const channel = payload?.channel || {};
|
||||
const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim();
|
||||
const 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 = `
|
||||
<div class="meta-muted">Не удалось открыть донат автору.</div>
|
||||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||||
<button class="secondary-btn" id="channel-donate-retry" type="button">Повторить</button>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
makeShineChannelAboutRoute,
|
||||
makeShineChannelDonateRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
@@ -306,6 +307,7 @@ function createChannelReadTracker({
|
||||
let persistedSeenCount = safeInitialSeenCount;
|
||||
let initialPersistPending = !!initializeIfMissing;
|
||||
let inFlight = false;
|
||||
let writeBlocked = false;
|
||||
let disposed = false;
|
||||
let rafId = 0;
|
||||
let timerId = 0;
|
||||
@@ -318,7 +320,7 @@ function createChannelReadTracker({
|
||||
};
|
||||
|
||||
const queueFlush = (delayMs = 180) => {
|
||||
if (disposed || !canWrite) return;
|
||||
if (disposed || writeBlocked || !canWrite) return;
|
||||
clearTimer();
|
||||
timerId = setTimeout(() => {
|
||||
timerId = 0;
|
||||
@@ -327,7 +329,7 @@ function createChannelReadTracker({
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
if (disposed || !canWrite) return;
|
||||
if (disposed || writeBlocked || !canWrite) return;
|
||||
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
||||
if (next <= persistedSeenCount && !initialPersistPending) return;
|
||||
if (inFlight) {
|
||||
@@ -358,6 +360,18 @@ function createChannelReadTracker({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode = String(error?.code || '').trim().toUpperCase();
|
||||
if (errorCode === 'CHANNEL_NOT_FOLLOWED') {
|
||||
// This is a persistent state mismatch, not a transient transport failure.
|
||||
// Stop writes for the current channel view instead of hammering the server
|
||||
// after every scroll/resize. A fresh view after subscribe/refresh will create
|
||||
// a new tracker from the latest subscription state.
|
||||
writeBlocked = true;
|
||||
initialPersistPending = false;
|
||||
clearTimer();
|
||||
if (typeof onPersistError === 'function') onPersistError(error, { retrying: false });
|
||||
return;
|
||||
}
|
||||
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
|
||||
queueFlush(800);
|
||||
} finally {
|
||||
@@ -656,7 +670,7 @@ function openChannelMetaDetailsModal({
|
||||
|
||||
function openAboutChannelModal(channel, options = {}) {
|
||||
openChannelMetaDetailsModal({
|
||||
title: 'О канале',
|
||||
title: 'Описание канала',
|
||||
channel,
|
||||
canEdit: options.canEdit === true,
|
||||
onEdit: options.onEdit,
|
||||
@@ -1582,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
|
||||
@@ -2411,46 +2429,54 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
||||
: 0;
|
||||
|
||||
const tracker = createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||
channelName: channelData.channel?.name || channelData.channel?.channelName,
|
||||
initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount: readCount,
|
||||
onPersistError: (error, options = {}) => {
|
||||
const detail = String(error?.message || '').trim();
|
||||
const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.';
|
||||
showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`);
|
||||
},
|
||||
onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => {
|
||||
channelData.readCount = persistedReadCount;
|
||||
channelData.unreadCount = persistedUnreadCount;
|
||||
channelData.readStateInitialized = true;
|
||||
const shouldTrackReadState = !!(
|
||||
channelData.isSubscribed
|
||||
&& !channelData.isOwnChannel
|
||||
&& !channelData.isDiary
|
||||
);
|
||||
|
||||
// The "Новые сообщения" divider is a snapshot of the unread boundary at the
|
||||
// moment this channel view was opened. Persisting read state must not move or
|
||||
// remove it during the current view session; reopening the channel recalculates it.
|
||||
const tracker = shouldTrackReadState
|
||||
? createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||
channelName: channelData.channel?.name || channelData.channel?.channelName,
|
||||
initializeIfMissing: !!(!channelData.readStateInitialized),
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount: readCount,
|
||||
onPersistError: (error, options = {}) => {
|
||||
const detail = String(error?.message || '').trim();
|
||||
const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.';
|
||||
showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`);
|
||||
},
|
||||
onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => {
|
||||
channelData.readCount = persistedReadCount;
|
||||
channelData.unreadCount = persistedUnreadCount;
|
||||
channelData.readStateInitialized = true;
|
||||
|
||||
const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels'];
|
||||
for (const group of feedGroups) {
|
||||
const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : [];
|
||||
const row = rows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '')
|
||||
&& Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber)
|
||||
&& normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash)
|
||||
));
|
||||
if (row) {
|
||||
row.readCount = persistedReadCount;
|
||||
row.unreadCount = persistedUnreadCount;
|
||||
row.readStateInitialized = true;
|
||||
// The "Новые сообщения" divider is a snapshot of the unread boundary at the
|
||||
// moment this channel view was opened. Persisting read state must not move or
|
||||
// remove it during the current view session; reopening the channel recalculates it.
|
||||
|
||||
const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels'];
|
||||
for (const group of feedGroups) {
|
||||
const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : [];
|
||||
const row = rows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '')
|
||||
&& Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber)
|
||||
&& normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash)
|
||||
));
|
||||
if (row) {
|
||||
row.readCount = persistedReadCount;
|
||||
row.unreadCount = persistedUnreadCount;
|
||||
row.readStateInitialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
showStatus('');
|
||||
},
|
||||
});
|
||||
showStatus('');
|
||||
},
|
||||
})
|
||||
: { cleanup() {}, measure() {} };
|
||||
|
||||
return () => {
|
||||
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
|
||||
@@ -2514,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',
|
||||
@@ -2523,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({
|
||||
|
||||
@@ -1426,7 +1426,7 @@ export function render({ navigate, route, chrome }) {
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
minWidth: 230,
|
||||
items: () => [
|
||||
|
||||
@@ -251,7 +251,7 @@ export function render({ navigate, chrome }) {
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn',
|
||||
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
|
||||
@@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
|
||||
const centerKey = normKey(snap?.centerLogin);
|
||||
const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||
const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey);
|
||||
if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 });
|
||||
if (ownCenter) centerNodeByKey.set(centerKey, {
|
||||
...ownCenter,
|
||||
isHistoryCenter: true,
|
||||
clusterOwnerId: centerKey,
|
||||
keepVisible: true,
|
||||
tier: 1,
|
||||
});
|
||||
|
||||
snapNodes.forEach((rawNode) => {
|
||||
const key = normKey(rawNode?.id);
|
||||
if (!key) return;
|
||||
// Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы
|
||||
// принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля.
|
||||
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key });
|
||||
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) {
|
||||
// Общий узел принадлежит последнему кластеру, возле которого он был перерисован.
|
||||
nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey });
|
||||
}
|
||||
|
||||
if (key === centerKey) return;
|
||||
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
|
||||
@@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
|
||||
id: key,
|
||||
login: node?.login || node?.id || key,
|
||||
tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1),
|
||||
isHistoryCenter: centerKeys.has(key),
|
||||
fixedLayout: true,
|
||||
keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible),
|
||||
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
|
||||
clusterOwnerId: normKey(node?.clusterOwnerId || key),
|
||||
edgeParents: edgeParentsByChild.get(key) || [],
|
||||
}));
|
||||
|
||||
@@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
}
|
||||
|
||||
function persistManualNodePosition(nodeId, point) {
|
||||
const key = normKey(nodeId);
|
||||
const x = Number(point?.x);
|
||||
const y = Number(point?.y);
|
||||
if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
function persistManualNodePositions(rows) {
|
||||
const positions = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
||||
const key = normKey(row?.id || row?.login);
|
||||
const x = Number(row?.x);
|
||||
const y = Number(row?.y);
|
||||
if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y });
|
||||
});
|
||||
if (!positions.size) return;
|
||||
|
||||
// Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все
|
||||
// упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag.
|
||||
@@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] });
|
||||
let changed = false;
|
||||
model.nodes = model.nodes.map((node) => {
|
||||
if (normKey(node?.id) !== key) return node;
|
||||
const point = positions.get(normKey(node?.id));
|
||||
if (!point) return node;
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
layoutX: x,
|
||||
layoutY: y,
|
||||
layoutX: point.x,
|
||||
layoutY: point.y,
|
||||
fixedLayout: true,
|
||||
};
|
||||
});
|
||||
@@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY });
|
||||
},
|
||||
// тап по центру — полноценный профиль
|
||||
// Короткий тап по текущему центру сразу открывает его меню.
|
||||
onCenterTap: (node) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
if (routeTo) navigate(routeTo);
|
||||
const rect = node?.el?.getBoundingClientRect?.();
|
||||
openNodeMenu({
|
||||
login: normalizeLogin(node?.login),
|
||||
displayName: String(node?.name || '').trim(),
|
||||
relationType: node?.relationType,
|
||||
point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined,
|
||||
actions: [
|
||||
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } },
|
||||
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) },
|
||||
],
|
||||
});
|
||||
},
|
||||
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
||||
onNodeLongPress: (node, point) => {
|
||||
@@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
// Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра
|
||||
// в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot.
|
||||
onNodeMoveEnd: (node, point) => {
|
||||
persistManualNodePosition(node?.id || node?.login, point);
|
||||
persistManualNodePositions([{ id: node?.id || node?.login, ...point }]);
|
||||
},
|
||||
// Центр и узлы, которые в последний раз были нарисованы возле него,
|
||||
// сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild.
|
||||
onGroupMoveEnd: (movedNodes) => {
|
||||
persistManualNodePositions(movedNodes);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,13 +219,14 @@ function hash01(str) {
|
||||
* @param {Object} opts
|
||||
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
|
||||
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
|
||||
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
|
||||
* @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void
|
||||
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
|
||||
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
||||
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
|
||||
* @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void
|
||||
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
||||
*/
|
||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
|
||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) {
|
||||
// Слои DOM
|
||||
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||||
edgesSvg.setAttribute('class', 'fg-edges');
|
||||
@@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.relationType = src.relationType;
|
||||
node.shining = Boolean(src.shining);
|
||||
node.official = Boolean(src.official);
|
||||
node.isHistoryCenter = Boolean(src.isHistoryCenter);
|
||||
node.clusterOwnerId = String(src.clusterOwnerId || src.id || '');
|
||||
node.keepVisible = Boolean(src.keepVisible);
|
||||
node.alwaysVisible = Boolean(src.alwaysVisible);
|
||||
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
||||
@@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let nodeDragActive = false;
|
||||
let nodeDragStartX = 0;
|
||||
let nodeDragStartY = 0;
|
||||
let groupDragActive = false;
|
||||
let groupDragNodes = [];
|
||||
let groupDragStarts = new Map();
|
||||
let longTimer = 0;
|
||||
let longFired = false;
|
||||
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
|
||||
@@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
||||
downNode = nodeFromEvent(ev);
|
||||
nodeDragActive = false;
|
||||
groupDragActive = false;
|
||||
groupDragNodes = [];
|
||||
groupDragStarts = new Map();
|
||||
nodeDragStartX = Number(downNode?.x) || 0;
|
||||
nodeDragStartY = Number(downNode?.y) || 0;
|
||||
if (downNode?.isHistoryCenter) {
|
||||
const ownerId = String(downNode.clusterOwnerId || downNode.id);
|
||||
groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId);
|
||||
groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }]));
|
||||
}
|
||||
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
|
||||
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
|
||||
if (downNode && typeof onNodeLongPress === 'function') {
|
||||
@@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
moved = true;
|
||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
|
||||
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
|
||||
nodeDragActive = Boolean(downNode && !downNode.isFocus);
|
||||
if (nodeDragActive && cssBloom) endCssBloom();
|
||||
// Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту.
|
||||
groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length);
|
||||
nodeDragActive = Boolean(downNode && !groupDragActive);
|
||||
if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom();
|
||||
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
|
||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
|
||||
cancelTween(); // жест прерывает анимацию центрирования
|
||||
dragging = true;
|
||||
}
|
||||
if (moved && groupDragActive) {
|
||||
const zx = dx / Math.max(0.001, zoom);
|
||||
const zy = dy / Math.max(0.001, zoom);
|
||||
groupDragNodes.forEach((node) => {
|
||||
const start = groupDragStarts.get(String(node.id));
|
||||
if (!start) return;
|
||||
const nx = start.x + zx;
|
||||
const ny = start.y + zy;
|
||||
node.x = nx; node.y = ny;
|
||||
node.tx = nx; node.ty = ny;
|
||||
node.bfx = nx; node.bfy = ny;
|
||||
node.vx = 0; node.vy = 0;
|
||||
node.fixedLayout = true;
|
||||
});
|
||||
renderNodes();
|
||||
renderEdges();
|
||||
return;
|
||||
}
|
||||
if (moved && nodeDragActive && downNode) {
|
||||
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
|
||||
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
|
||||
@@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const wasMoved = moved;
|
||||
const wasLong = longFired;
|
||||
const movedNode = nodeDragActive ? downNode : null;
|
||||
const movedGroup = groupDragActive ? [...groupDragNodes] : [];
|
||||
pointerId = null;
|
||||
dragging = false;
|
||||
nodeDragActive = false;
|
||||
groupDragActive = false;
|
||||
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
|
||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||
|
||||
@@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
|
||||
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
|
||||
renderEdges();
|
||||
} else if (wasMoved && movedGroup.length) {
|
||||
if (typeof onGroupMoveEnd === 'function') {
|
||||
onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y })));
|
||||
}
|
||||
renderEdges();
|
||||
} else if (wasMoved) {
|
||||
// после pan даём физике чуть устаканиться и уснуть
|
||||
wake();
|
||||
@@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
}
|
||||
|
||||
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
||||
const finalX = node.isFocus ? 0 : node.tx;
|
||||
const finalY = node.isFocus ? 0 : node.ty;
|
||||
// Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel.
|
||||
const finalX = node.tx;
|
||||
const finalY = node.ty;
|
||||
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
|
||||
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
|
||||
|
||||
|
||||
@@ -320,11 +320,15 @@ function renderItem(item, activeTab, navigate) {
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||
chrome?.setTopbar(createTopBar({
|
||||
center: tabs,
|
||||
className: 'notifications-topbar',
|
||||
}));
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
let payloadCache = null;
|
||||
@@ -415,7 +419,7 @@ export function render({ navigate, chrome } = {}) {
|
||||
observer?.disconnect();
|
||||
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
screen.append(tabs,list);
|
||||
screen.append(list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function render({ navigate, chrome }) {
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню профиля',
|
||||
ariaLabel: 'Меню профиля',
|
||||
className: 'profile-head-menu-btn',
|
||||
className: 'profile-head-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 250,
|
||||
@@ -176,6 +176,7 @@ export function render({ navigate, chrome }) {
|
||||
size: 'xl',
|
||||
className: 'user-profile-hero-avatar',
|
||||
glow: shining,
|
||||
official,
|
||||
}));
|
||||
|
||||
status.textContent = '';
|
||||
|
||||
@@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) {
|
||||
size: 'xl',
|
||||
className: 'user-profile-hero-avatar',
|
||||
glow: shining,
|
||||
official,
|
||||
}));
|
||||
|
||||
addMenu = body.querySelector('.user-profile-add-menu');
|
||||
|
||||
+55
-13
@@ -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]) : '' } };
|
||||
}
|
||||
|
||||
// Публичная короткая ссылка канала не содержит внутренний номер блокчейна:
|
||||
// /<login>/<channel>. Старый /<login>-001/<channel> обрабатывается выше.
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const encoder = new TextEncoder();
|
||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
|
||||
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
|
||||
import { argon2idAsync } from '../vendor/noble/hashes-argon2-1.8.0.bundle.mjs';
|
||||
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from '../vendor/noble/curves-ed25519-1.8.1.bundle.mjs';
|
||||
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
|
||||
|
||||
function getCryptoApi() {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
edwardsToMontgomeryPriv,
|
||||
edwardsToMontgomeryPub,
|
||||
x25519,
|
||||
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
|
||||
} from '../vendor/noble/curves-ed25519-1.5.0.bundle.mjs';
|
||||
|
||||
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
|
||||
const PAIRING_HASH_PREFIX = 'sha256$';
|
||||
|
||||
@@ -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 = '' }) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -269,3 +269,23 @@
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Единый знак официального пользователя. Левый и нижний края зафиксированы,
|
||||
а сам знак растёт вправо и вверх вместе с аватаром. */
|
||||
.avatar.avatar-image.avatar-framed > .avatar-official-badge {
|
||||
position: absolute;
|
||||
left: -2%;
|
||||
bottom: -1%;
|
||||
width: 32%;
|
||||
height: 32%;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
@@ -26,3 +26,12 @@
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 10px rgba(72, 145, 255, 0.34);
|
||||
}
|
||||
|
||||
|
||||
/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной
|
||||
* позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем
|
||||
* выпадающего меню, не меняя базовый TopBar для остальных экранов. */
|
||||
.topbar-overflow-action--raised {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@
|
||||
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
|
||||
}
|
||||
|
||||
.topbar-slot .topbar__action {
|
||||
min-width: 40px;
|
||||
padding-block: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-slot .topbar__action svg,
|
||||
.topbar-slot .topbar__back svg {
|
||||
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,14 +186,32 @@
|
||||
|
||||
|
||||
|
||||
/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */
|
||||
.notifications-screen .notification-feed-tabs {
|
||||
/* Уведомления: переключатели лент занимают место заголовка в закреплённом
|
||||
* TopBar и повторяют стеклянные чипы экрана «Связи». */
|
||||
.notifications-topbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
|
||||
.notifications-topbar .topbar__left,
|
||||
.notifications-topbar .topbar__right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.notifications-topbar .topbar__center {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.notifications-topbar .notification-feed-tabs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 4px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -201,25 +219,22 @@
|
||||
}
|
||||
|
||||
|
||||
.notifications-screen .notification-feed-tabs .notification-tab-btn {
|
||||
min-width: 92px;
|
||||
.notifications-topbar .notification-feed-tabs .notification-tab-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */
|
||||
.notifications-screen .notification-feed-tabs.app-top-tabs {
|
||||
/* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */
|
||||
.notifications-topbar .notification-feed-tabs.app-top-tabs {
|
||||
min-height: var(--app-primary-tab-min-height);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.notifications-screen .notification-feed-tabs .notification-tab-btn {
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
|
||||
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
|
||||
* обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */
|
||||
.notifications-screen .notifications-list > .notification-card,
|
||||
|
||||
@@ -799,8 +799,26 @@
|
||||
|
||||
|
||||
.user-profile-screen .user-profile-hero-avatar.avatar-glow::before {
|
||||
inset: -12%;
|
||||
opacity: 0.5;
|
||||
inset: -7%;
|
||||
border: 2px solid rgba(145, 239, 255, 0.94);
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
0 0 3px rgba(155, 244, 255, 0.96),
|
||||
0 0 9px rgba(112, 222, 255, 0.74),
|
||||
0 0 17px rgba(86, 190, 255, 0.38),
|
||||
inset 0 0 4px rgba(176, 248, 255, 0.72);
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* На крупном аватаре профиля официальный знак на 20% компактнее базового.
|
||||
* Левый и нижний края остаются на месте, поэтому сокращение приходится на
|
||||
* верхнюю и правую стороны знака. */
|
||||
.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge {
|
||||
width: 25.6%;
|
||||
height: 25.6%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -210,10 +210,10 @@
|
||||
position: absolute;
|
||||
left: -2%;
|
||||
bottom: -1%;
|
||||
width: 16%;
|
||||
height: 16%;
|
||||
min-width: 9px;
|
||||
min-height: 9px;
|
||||
width: 32%;
|
||||
height: 32%;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user