Синхронизировать регистр логинов и доработать UI каналов

This commit is contained in:
AidarKC
2026-08-19 14:30:29 +04:00
parent e8a4713f6e
commit ce30b77328
29 changed files with 6729 additions and 815 deletions
+6 -473
View File
@@ -1,13 +1,8 @@
import {
navigate,
getRoute,
parseRouteFromPath,
PRE_AUTH_PAGES,
getSwipeNavigationTarget,
syncTrackedRouteHistory,
rememberToolbarRoute,
resetRememberedToolbarRoutes,
resolveToolbarActive,
} from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
@@ -173,14 +168,6 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50;
const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']);
const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72;
const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56;
const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35;
const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14;
const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32;
const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18;
const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260;
let currentCleanup = null;
let pingIntervalId = null;
@@ -202,9 +189,6 @@ let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = false;
let orientationLockInFlight = false;
let currentChromeCleanup = null;
let currentMountState = null;
let activeSwipePreview = null;
const keepAliveEntries = new Map();
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
@@ -319,403 +303,11 @@ function createChromeController(showAppChrome) {
};
}
function destroyMountState(entry) {
if (!entry) return;
if (entry.destroyed) return;
entry.destroyed = true;
try {
if (typeof entry.cleanup === 'function') {
entry.cleanup();
}
} finally {
entry.chrome?.dispose?.();
}
}
function clearKeepAliveEntries() {
teardownSwipePreview({ cancelOnly: true });
keepAliveEntries.forEach((entry) => destroyMountState(entry));
keepAliveEntries.clear();
resetRememberedToolbarRoutes();
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
}
function detachMountedScreen(entry) {
if (!entry) return;
entry.chrome?.suspend?.();
if (entry.screen?.parentNode === screenEl) {
screenEl.removeChild(entry.screen);
} else {
screenEl.innerHTML = '';
}
}
function mountExistingEntry(entry, { showAppChrome, pageId }) {
teardownSwipePreview({ cancelOnly: true });
screenEl.innerHTML = '';
screenEl.append(entry.screen);
entry.chrome?.resume?.();
currentMountState = entry;
currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null;
currentChromeCleanup = () => entry.chrome?.dispose?.();
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
}
function cloneScreenForSwipe(screen) {
const clone = screen?.cloneNode?.(true);
if (!(clone instanceof Node)) return null;
return clone;
}
function sanitizeSwipeClone(node) {
if (!(node instanceof Element)) return;
node.removeAttribute('id');
node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
}
function cloneSlotChildForSwipe(slotEl) {
const child = slotEl?.firstElementChild;
if (!(child instanceof Node)) return null;
const clone = child.cloneNode(true);
if (clone instanceof Element) sanitizeSwipeClone(clone);
return clone;
}
function createSwipeFrameSlot(className, contentNode = null) {
const slot = document.createElement('div');
slot.className = className;
if (contentNode instanceof Node) {
slot.append(contentNode);
slot.hidden = false;
} else {
slot.hidden = true;
}
return slot;
}
function buildSwipePane({
topbarNode = null,
screenNode = null,
composerNode = null,
screenClassName = '',
screenScrollTop = 0,
}) {
const pane = document.createElement('div');
pane.className = 'screen-swipe-pane';
const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode);
const screenSlot = document.createElement('main');
screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`;
if (screenNode instanceof Node) {
screenSlot.append(screenNode);
}
const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode);
pane.append(topbarSlot, screenSlot, composerSlot);
requestAnimationFrame(() => {
screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0));
});
return pane;
}
function createSwipePreviewTarget(targetPath) {
const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`);
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
const page = routes[pageId] || routes['start-view'];
const rootPageId = resolveToolbarActive(pageId);
const cachedEntry = keepAliveEntries.get(rootPageId);
if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) {
return {
screen: cloneScreenForSwipe(cachedEntry.screen),
cleanup: null,
};
}
let previewTopbarNode = null;
let previewComposerNode = null;
const chrome = {
setTopbar(node = null) {
previewTopbarNode = node instanceof Node ? node : null;
},
setComposer(node = null) {
previewComposerNode = node instanceof Node ? node : null;
},
clear() {
previewTopbarNode = null;
previewComposerNode = null;
},
suspend() {},
resume() {},
dispose() {},
};
const screen = page.render({ route, navigate, chrome });
if (!(screen instanceof Node)) {
chrome.dispose();
throw new Error('Swipe preview render returned invalid node');
}
const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
return {
screen,
topbarNode: previewTopbarNode,
composerNode: previewComposerNode,
cleanup: () => {
try {
if (typeof cleanup === 'function') cleanup();
} finally {
chrome.dispose();
}
},
};
}
function applySwipePreviewOffset(session, revealPx) {
if (!session) return;
const width = Math.max(1, session.width);
const clamped = Math.max(0, Math.min(width, revealPx));
session.revealPx = clamped;
const currentX = session.direction === 'left' ? -clamped : clamped;
const targetX = session.direction === 'left'
? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX
: -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX;
const dividerX = session.direction === 'left'
? width - clamped
: clamped;
session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`;
session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`;
session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`;
const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24));
session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3));
}
function teardownSwipePreview({ cancelOnly = false } = {}) {
const session = activeSwipePreview;
if (!session) return;
activeSwipePreview = null;
appShellEl?.classList.remove('app-shell--swiping');
topbarEl?.classList.remove('topbar-slot--swipe-hidden');
screenEl.classList.remove('screen-content--swipe-hidden');
composerEl?.classList.remove('composer-slot--swipe-hidden');
session.overlay.remove();
if (typeof session.targetCleanup === 'function') {
session.targetCleanup();
}
if (!cancelOnly) {
session.onComplete?.();
}
}
function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) {
const width = Math.max(1, session.width);
const currentReveal = Number(session.revealPx || 0);
const remaining = Math.abs(revealPx - currentReveal);
const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS)));
[session.currentPane, session.targetPane, session.divider].forEach((node) => {
node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
});
session.overlay.style.transition = `opacity ${duration}ms ease`;
requestAnimationFrame(() => {
applySwipePreviewOffset(session, revealPx);
if (!complete) {
session.overlay.style.opacity = '0';
}
});
window.setTimeout(() => {
teardownSwipePreview({ cancelOnly: !complete });
}, duration + 24);
}
function beginSwipePreview(direction, targetPath) {
if (!currentMountState?.screen || activeSwipePreview) return null;
const currentTopbarClone = cloneSlotChildForSwipe(topbarEl);
const currentScreenClone = cloneScreenForSwipe(currentMountState.screen);
const currentComposerClone = cloneSlotChildForSwipe(composerEl);
if (!(currentScreenClone instanceof Node)) return null;
if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone);
if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone);
if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone);
const targetPreview = createSwipePreviewTarget(targetPath);
if (!(targetPreview?.screen instanceof Node)) {
targetPreview?.cleanup?.();
return null;
}
if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode);
if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen);
if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode);
const overlay = document.createElement('div');
overlay.className = 'screen-swipe-overlay';
const currentPane = buildSwipePane({
topbarNode: currentTopbarClone,
screenNode: currentScreenClone,
composerNode: currentComposerClone,
screenClassName: screenEl.className,
screenScrollTop: screenEl.scrollTop,
});
currentPane.classList.add('screen-swipe-pane--current');
const targetPane = buildSwipePane({
topbarNode: targetPreview.topbarNode || null,
screenNode: targetPreview.screen,
composerNode: targetPreview.composerNode || null,
screenClassName: screenEl.className,
screenScrollTop: 0,
});
targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`);
const divider = document.createElement('div');
divider.className = 'screen-swipe-divider';
overlay.append(currentPane, targetPane, divider);
appShellEl.append(overlay);
appShellEl?.classList.add('app-shell--swiping');
topbarEl?.classList.add('topbar-slot--swipe-hidden');
screenEl.classList.add('screen-content--swipe-hidden');
composerEl?.classList.add('composer-slot--swipe-hidden');
const session = {
direction,
targetPath,
width: screenEl.clientWidth || 1,
overlay,
currentPane,
targetPane,
divider,
targetCleanup: targetPreview.cleanup || null,
revealPx: 0,
onComplete: () => navigate(targetPath),
};
activeSwipePreview = session;
applySwipePreviewOffset(session, 0);
return session;
}
function installHorizontalTabSwipe() {
if (!screenEl) return;
let touchStartX = 0;
let touchStartY = 0;
let touchActive = false;
let touchBlocked = false;
let swipeLocked = false;
let swipeDirection = '';
let swipeTargetPath = '';
let swipeSession = null;
const reset = () => {
touchActive = false;
touchBlocked = false;
swipeLocked = false;
swipeDirection = '';
swipeTargetPath = '';
swipeSession = null;
touchStartX = 0;
touchStartY = 0;
};
screenEl.addEventListener('touchstart', (event) => {
if (event.touches.length !== 1) {
reset();
return;
}
const target = event.target instanceof Element ? event.target : null;
touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]'));
touchActive = !touchBlocked;
touchStartX = Number(event.touches[0]?.clientX || 0);
touchStartY = Number(event.touches[0]?.clientY || 0);
}, { passive: true });
screenEl.addEventListener('touchmove', (event) => {
if (!touchActive || touchBlocked) return;
const touch = event.touches?.[0];
const deltaX = Number(touch?.clientX || 0) - touchStartX;
const deltaY = Number(touch?.clientY || 0) - touchStartY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (!swipeLocked) {
if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return;
if (absX <= absY * 1.05) {
touchBlocked = true;
return;
}
const currentPageId = getRoute().pageId || '';
swipeDirection = deltaX < 0 ? 'left' : 'right';
swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection);
if (!swipeTargetPath) {
touchBlocked = true;
return;
}
swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath);
if (!swipeSession) {
touchBlocked = true;
return;
}
swipeLocked = true;
}
if (!swipeLocked || !swipeSession) return;
event.preventDefault();
const revealPx = swipeDirection === 'left'
? Math.max(0, -deltaX)
: Math.max(0, deltaX);
applySwipePreviewOffset(swipeSession, revealPx);
}, { passive: false });
screenEl.addEventListener('touchcancel', reset, { passive: true });
screenEl.addEventListener('touchend', (event) => {
if (!touchActive || touchBlocked) {
if (swipeSession) {
animateSwipePreviewTo(swipeSession, 0, { complete: false });
}
reset();
return;
}
const touch = event.changedTouches?.[0];
const endX = Number(touch?.clientX || 0);
const endY = Number(touch?.clientY || 0);
const deltaX = endX - touchStartX;
const deltaY = endY - touchStartY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
const session = swipeSession;
const wasLocked = swipeLocked;
reset();
if (wasLocked && session) {
event.preventDefault();
const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width);
const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO;
animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit });
return;
}
if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return;
if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return;
if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return;
const currentPageId = getRoute().pageId || '';
const direction = deltaX < 0 ? 'left' : 'right';
const target = getSwipeNavigationTarget(currentPageId, direction);
if (!target) return;
navigate(target);
}, { passive: true });
}
async function unlockHiddenDmAudio() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -1396,7 +988,6 @@ function renderPageFailureFallback(pageId, error) {
});
screenEl.innerHTML = '';
teardownSwipePreview({ cancelOnly: true });
const wrap = document.createElement('section');
wrap.className = 'stack';
@@ -1433,7 +1024,6 @@ function renderPageFailureFallback(pageId, error) {
}
function renderApp() {
teardownSwipePreview({ cancelOnly: true });
syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
@@ -1450,56 +1040,13 @@ function renderApp() {
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
const rootPageId = resolveToolbarActive(pageId);
const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId);
const currentRoutePath = String(window.location.pathname || '/');
rememberToolbarRoute(pageId);
if (currentMountState) {
const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId;
if (shouldPreserveCurrent) {
currentMountState.routePath = currentMountState.routePath || currentRoutePath;
keepAliveEntries.set(currentMountState.rootPageId, currentMountState);
detachMountedScreen(currentMountState);
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
} else {
destroyMountState(currentMountState);
if (currentMountState.keepAlive) {
keepAliveEntries.delete(currentMountState.rootPageId);
}
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
}
} else {
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null;
if (cachedEntry && cachedEntry.routePath === currentRoutePath) {
mountExistingEntry(cachedEntry, { showAppChrome, pageId });
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
return;
}
if (cachedEntry) {
destroyMountState(cachedEntry);
keepAliveEntries.delete(rootPageId);
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
try {
@@ -1513,19 +1060,6 @@ function renderApp() {
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
currentMountState = {
pageId,
rootPageId,
keepAlive: keepAliveEligible,
routePath: currentRoutePath,
screen,
cleanup: currentCleanup,
chrome,
destroyed: false,
};
if (keepAliveEligible) {
keepAliveEntries.set(rootPageId, currentMountState);
}
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
@@ -2008,7 +1542,6 @@ async function init() {
})();
window.addEventListener('popstate', renderApp);
installHorizontalTabSwipe();
document.addEventListener('pointerdown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
onSelect,
selectedTxIds = [],
historyOnly = false,
persistToHistory = true,
allowHistorySelection = true,
allowExistingTxInput = true,
mode = 'attachment',
historyPurpose = '',
uploadTransport = 'turbo',
turboKeySource = 'client',
dialogTitle = '',
uploadButtonLabel = '',
initialFile = null,
initialSha256 = '',
initialName = '',
fixedFile = false,
autoOpenFileDialog = true,
shineType = '',
extraUploadTags = [],
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
let selectedPreviewPriceInfo = null;
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
let autoOpenedFileDialogOnce = false;
const isAvatarMode = String(mode || '') === 'avatar';
if (isAvatarMode && !String(uploadTransport || '').trim()) {
selectedUploadTransport = 'turbo';
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
const historyPurposeMode = String(historyPurpose || '').trim();
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
const effectiveDialogTitle = String(dialogTitle || '').trim();
const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim();
const forcedShineType = String(shineType || '').trim();
const normalizedExtraUploadTags = Array.isArray(extraUploadTags)
? extraUploadTags.filter((item) => item?.name && item?.value)
: [];
if (initialFile instanceof File) {
selectedFile = initialFile;
if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase();
}
function isTurboUpload() {
return selectedUploadTransport === 'turbo';
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
}
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement,
markPlaced: false,
});
const item = persistToHistory
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement,
markPlaced: false,
})
: {
...attachment,
...normalizeAttachment(attachment),
};
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
close(resolve, item);
}
@@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({
const showUpload = async () => {
const turboMode = isTurboUpload();
const titleText = effectiveDialogTitle
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput;
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
<div class="form-actions-grid">
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
</div>
${turboMode
? `
@@ -611,8 +643,8 @@ export function openArweaveAttachmentManager({
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
`}
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
<div class="ar-attachment-meta" data-meta-main="true"></div>
<label class="meta-muted" data-preview-option="true" hidden>
<input type="checkbox" data-preview-toggle="true" />
@@ -622,7 +654,7 @@ export function openArweaveAttachmentManager({
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
@@ -818,27 +850,30 @@ export function openArweaveAttachmentManager({
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
...normalizedExtraUploadTags,
],
})
: await uploadArweaveFile({
gateway: cleanGateway,
jwk: selectedWallet()?.jwk,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
...normalizedExtraUploadTags,
],
});
finish(resolve, {
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadTransport: turboMode ? 'turbo' : 'arweave',
preview: previewUpload?.id && selectedPreviewSha256
? {
ar: previewUpload.id,
@@ -854,8 +889,19 @@ export function openArweaveAttachmentManager({
}
});
if (!autoOpenedFileDialog) {
autoOpenedFileDialog = true;
if (selectedFile && selectedSha256) {
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
previewOptionEl.hidden = false;
}
if (turboMode) {
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
}
}
if (!fixedFile && autoOpenFileDialog && !autoOpenedFileDialogOnce) {
autoOpenedFileDialogOnce = true;
window.setTimeout(() => fileEl?.click(), 0);
}
};
+4 -4
View File
@@ -1,4 +1,4 @@
import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js';
import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
@@ -33,7 +33,7 @@ function getTotalUnreadMessages() {
function navigateWithGuestRules(pageId, navigate) {
if (state.session.isAuthorized) {
navigate(getToolbarNavigationTarget(pageId));
navigate(pageId);
return;
}
if (pageId === 'messages-list') {
@@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) {
});
return;
}
navigate(getToolbarNavigationTarget(pageId));
navigate(pageId);
}
export function renderToolbar(currentPageId, navigate) {
@@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
btn.append(badge);
}
if (item.pageId === 'channels-list') {
btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list')));
btn.addEventListener('click', () => navigate('channels-list'));
} else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
}
+1 -1
View File
@@ -973,8 +973,8 @@ function openTopChannelsMenu({
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
{ divider: true },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ label: 'Создать канал', action: () => onSubscribeChannel?.() },
{ divider: true },
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
];
+429 -84
View File
@@ -17,6 +17,8 @@ import {
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
@@ -24,6 +26,12 @@ import {
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
import {
buildBlockchainSnapshotFile,
clearStoredBlockchainSnapshot,
readStoredBlockchainSnapshot,
saveStoredBlockchainSnapshot,
} from '../services/shine-blockchain-snapshot-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
@@ -40,6 +48,17 @@ function formatKbFromBytes(rawBytes) {
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
}
function formatBytesRu(rawBytes) {
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
return bytes.toLocaleString('ru-RU');
}
function normalizeHex64(value) {
const raw = String(value || '').trim().toLowerCase();
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
}
function lamportsToSolText(lamportsBigInt) {
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
@@ -461,6 +480,55 @@ export function render({ navigate }) {
arweaveWalletCtx = null;
}
async function fetchServerBlockchainState() {
const login = String(state.session.login || '').trim();
const user = await authService.getUser(login);
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
return {
login,
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
};
}
async function resolveWalletSigningMaterial() {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
@@ -1245,6 +1313,14 @@ export function render({ navigate }) {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
@@ -1253,7 +1329,7 @@ export function render({ navigate }) {
void renderSupportHub();
});
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
}
@@ -1339,63 +1415,13 @@ export function render({ navigate }) {
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
`;
const refreshBtn = actions.querySelector('#refresh-shine-bch');
const syncBtn = actions.querySelector('#sync-shine-solana');
const topupBtn = actions.querySelector('#topup-shine-limit');
const fetchServerState = async () => {
const user = await authService.getUser(String(state.session.login || '').trim());
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
return {
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber,
lastHash: String(user.serverLastGlobalHash || ''),
};
};
const resolveWalletSigningMaterial = async () => {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
};
const setButtonsDisabled = (disabled) => {
refreshBtn.disabled = disabled;
syncBtn.disabled = disabled;
topupBtn.disabled = disabled;
};
@@ -1407,7 +1433,7 @@ export function render({ navigate }) {
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerState(),
fetchServerBlockchainState(),
]);
if (modeToken !== activeModeToken) return;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
@@ -1423,9 +1449,6 @@ export function render({ navigate }) {
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
@@ -1435,35 +1458,6 @@ export function render({ navigate }) {
}
};
syncBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
const [serverState, signing] = await Promise.all([
fetchServerState(),
resolveWalletSigningMaterial(),
]);
const result = await updateShineUserPdaOnSolana({
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
});
if (modeToken !== activeModeToken) return;
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
await refreshUsage();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
} finally {
setButtonsDisabled(false);
}
});
topupBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
@@ -1517,6 +1511,357 @@ export function render({ navigate }) {
await refreshUsage();
}
async function renderSolanaPublishWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const login = String(state.session.login || '').trim();
const backBtn = createModeBackButton(renderWalletChoice);
const summaryCard = document.createElement('div');
summaryCard.className = 'card stack';
summaryCard.innerHTML = `
<h2 style="margin:0;">Закрепление в Solana</h2>
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
`;
const publicationStatus = document.createElement('div');
publicationStatus.className = 'card stack';
publicationStatus.innerHTML = `
<h3 style="margin:0;">Статус публикации</h3>
<p class="meta-muted" id="publish-status-main"></p>
<p class="meta-muted" id="publish-status-local"></p>
`;
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
const pdaCard = document.createElement('div');
pdaCard.className = 'card stack';
pdaCard.innerHTML = `
<h3 style="margin:0;">Что закреплено в PDA</h3>
<p class="meta-muted" id="pda-name">Блокчейн: </p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: </p>
<p class="meta-muted" id="pda-last">Крайний блок: </p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="pda-used">Размер: </p>
<p class="meta-muted" id="pda-limit">Лимит: </p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: </p>
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
`;
const serverCard = document.createElement('div');
serverCard.className = 'card stack';
serverCard.innerHTML = `
<h3 style="margin:0;">Что реально на сервере</h3>
<p class="meta-muted" id="server-name">Блокчейн: </p>
<p class="meta-muted" id="server-last">Крайний блок: </p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="server-size">Размер: </p>
<p class="meta-muted" id="server-updated">Обновлено: </p>
`;
const localCard = document.createElement('div');
localCard.className = 'card stack';
localCard.innerHTML = `
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
<p class="meta-muted" id="local-state">Слепок: </p>
<p class="meta-muted" id="local-transport">Способ: </p>
<p class="meta-muted" id="local-last">Крайний блок: </p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="local-size">Размер: </p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: </p>
<p class="meta-muted" id="local-created">Создан: </p>
<p class="meta-muted" id="local-solana">Solana: </p>
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
`;
const refreshBtn = actions.querySelector('#refresh-solana-publish');
const createBtn = actions.querySelector('#create-solana-snapshot');
const publishBtn = actions.querySelector('#publish-solana-snapshot');
const clearBtn = actions.querySelector('#clear-solana-snapshot');
const pdaNameEl = pdaCard.querySelector('#pda-name');
const pdaAddressEl = pdaCard.querySelector('#pda-address');
const pdaLastEl = pdaCard.querySelector('#pda-last');
const pdaHashEl = pdaCard.querySelector('#pda-hash');
const pdaUsedEl = pdaCard.querySelector('#pda-used');
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
const serverNameEl = serverCard.querySelector('#server-name');
const serverLastEl = serverCard.querySelector('#server-last');
const serverHashEl = serverCard.querySelector('#server-hash');
const serverSizeEl = serverCard.querySelector('#server-size');
const serverUpdatedEl = serverCard.querySelector('#server-updated');
const localStateEl = localCard.querySelector('#local-state');
const localTransportEl = localCard.querySelector('#local-transport');
const localLastEl = localCard.querySelector('#local-last');
const localHashEl = localCard.querySelector('#local-hash');
const localSizeEl = localCard.querySelector('#local-size');
const localArweaveEl = localCard.querySelector('#local-arweave');
const localCreatedEl = localCard.querySelector('#local-created');
const localSolanaEl = localCard.querySelector('#local-solana');
function setButtonsDisabled(disabled) {
refreshBtn.disabled = disabled;
createBtn.disabled = disabled;
publishBtn.disabled = disabled;
clearBtn.disabled = disabled;
}
async function refreshState() {
setButtonsDisabled(true);
try {
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const localSnapshot = readStoredBlockchainSnapshot(login);
if (modeToken !== activeModeToken) return;
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
if (!usage.arweaveTxId) {
publicationMainEl.textContent = 'не опубликовано';
} else if (pdaDelta > 0) {
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
} else {
publicationMainEl.textContent = 'опубликовано и полностью актуально';
}
let localStatusText = 'Локального слепка нет.';
const localMatchesServer = localSnapshot
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
if (localSnapshot?.txId && localMatchesServer) {
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
} else {
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
}
} else if (localSnapshot?.txId) {
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
}
publicationLocalEl.textContent = localStatusText;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
if (usage.arweaveTxId) {
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
pdaArweaveLinkEl.style.pointerEvents = 'auto';
pdaArweaveLinkEl.style.opacity = '1';
} else {
pdaArweaveLinkEl.href = '#';
pdaArweaveLinkEl.style.pointerEvents = 'none';
pdaArweaveLinkEl.style.opacity = '.55';
}
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
if (localSnapshot?.txId) {
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
: 'Solana: слепок ещё не закреплён';
} else {
localStateEl.textContent = 'Слепок: отсутствует';
localTransportEl.textContent = 'Способ: —';
localLastEl.textContent = 'Крайний блок: —';
localHashEl.textContent = 'Hash: —';
localSizeEl.textContent = 'Размер: —';
localArweaveEl.textContent = 'Arweave tx: —';
localCreatedEl.textContent = 'Создан: —';
localSolanaEl.textContent = 'Solana: —';
}
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
clearBtn.disabled = !localSnapshot;
setStatus('Состояние публикации обновлено.');
} catch (error) {
if (modeToken !== activeModeToken) return;
publishBtn.disabled = true;
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
createBtn.disabled = false;
}
}
async function createSnapshot() {
setButtonsDisabled(true);
try {
setStatus('Собираю полный слепок блокчейна с сервера...');
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const snapshotFile = await buildBlockchainSnapshotFile({
authService,
login,
blockchainName: usage.blockchainName || serverState.blockchainName,
lastBlockNumber: serverState.lastNumber,
});
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
if (modeToken !== activeModeToken) return;
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
const uploaded = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: false,
persistToHistory: false,
allowHistorySelection: false,
allowExistingTxInput: false,
uploadTransport: 'turbo',
dialogTitle: 'Сделать слепок блокчейна',
uploadButtonLabel: 'Сделать слепок',
initialFile: snapshotFile.file,
initialSha256: snapshotSha256,
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
fixedFile: true,
autoOpenFileDialog: false,
shineType: 'blockchain-snapshot',
extraUploadTags: [
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
{ name: 'SHiNE-Login', value: login },
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
],
});
if (modeToken !== activeModeToken) return;
if (!uploaded?.ar) {
setStatus('Загрузка слепка отменена.');
return;
}
saveStoredBlockchainSnapshot(login, {
blockchainName: snapshotFile.blockchainName,
txId: String(uploaded.ar || '').trim(),
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
usedBytes: Number(serverState.sizeBytes || 0),
lastBlockNumber: Number(serverState.lastNumber),
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
uploadedAtMs: Date.now(),
});
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
async function publishSnapshotToSolana() {
setButtonsDisabled(true);
try {
const localSnapshot = readStoredBlockchainSnapshot(login);
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
const [usage, serverState, signing] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
resolveWalletSigningMaterial(),
]);
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
throw new Error('Локальный слепок относится к другому блокчейну.');
}
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
}
const result = await updateShineUserPdaOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
nextArweaveTxId: localSnapshot.txId,
});
if (modeToken !== activeModeToken) return;
saveStoredBlockchainSnapshot(login, {
...localSnapshot,
solanaUpdatedAtMs: Date.now(),
solanaSignature: String(result.signature || '').trim(),
});
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
refreshBtn.addEventListener('click', () => {
void refreshState();
});
createBtn.addEventListener('click', () => {
void createSnapshot();
});
publishBtn.addEventListener('click', () => {
void publishSnapshotToSolana();
});
clearBtn.addEventListener('click', () => {
clearStoredBlockchainSnapshot(login);
setStatus('Локальный слепок удалён из браузера.');
void refreshState();
});
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
setStatus('Загрузка состояния публикации...');
await refreshState();
}
async function renderSolanaWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
+7 -41
View File
@@ -1,8 +1,5 @@
import { parseShineRouteParts } from './services/shine-routes.js';
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
const SWIPEABLE_ROOT_PAGES = ['messages-list', 'channels-list', 'notifications-view', 'profile-view'];
const lastVisitedRouteByRoot = new Map();
let previousTrackedPath = '';
let currentTrackedPath = String(window.location.pathname || '').trim() || '/';
const PRETTY_PATHS = new Map([
@@ -365,12 +362,6 @@ export function navigate(path) {
window.dispatchEvent(new PopStateEvent('popstate'));
}
function normalizeCurrentPath() {
return String(window.location.pathname || '')
.replace(/^\/+/, '')
.replace(/\/+$/, '');
}
export function toPrettyPath(path) {
const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!raw) return '';
@@ -387,7 +378,13 @@ export function navigateBack() {
}
export function resolveToolbarActive(pageId) {
if (ROOT_PAGES.includes(pageId)) return pageId;
if (
pageId === 'messages-list'
|| pageId === 'channels-list'
|| pageId === 'network-view'
|| pageId === 'notifications-view'
|| pageId === 'profile-view'
) return pageId;
if (
pageId === 'profile-edit-view' ||
pageId === 'wallet-view' ||
@@ -416,37 +413,6 @@ export function resolveToolbarActive(pageId) {
return 'profile-view';
}
export function rememberToolbarRoute(pageId, explicitPath = '') {
const rootPageId = resolveToolbarActive(pageId);
if (!ROOT_PAGES.includes(rootPageId)) return;
const cleanPath = String(explicitPath || normalizeCurrentPath()).trim();
lastVisitedRouteByRoot.set(rootPageId, cleanPath || toPrettyPath(rootPageId) || rootPageId);
}
export function getToolbarNavigationTarget(pageId) {
const rootPageId = resolveToolbarActive(pageId);
return lastVisitedRouteByRoot.get(rootPageId) || toPrettyPath(rootPageId) || rootPageId;
}
export function resetRememberedToolbarRoutes() {
lastVisitedRouteByRoot.clear();
previousTrackedPath = '';
currentTrackedPath = String(window.location.pathname || '').trim() || '/';
}
export function getSwipeNavigationTarget(currentPageId, direction) {
const rootPageId = resolveToolbarActive(currentPageId);
const index = SWIPEABLE_ROOT_PAGES.indexOf(rootPageId);
if (index === -1) return '';
const step = direction === 'left' ? 1 : direction === 'right' ? -1 : 0;
if (!step) return '';
const nextIndex = index + step;
if (nextIndex < 0 || nextIndex >= SWIPEABLE_ROOT_PAGES.length) return '';
return getToolbarNavigationTarget(SWIPEABLE_ROOT_PAGES[nextIndex]);
}
export function syncTrackedRouteHistory(pathname = '') {
const nextPath = String(pathname || '').trim() || '/';
if (nextPath === currentTrackedPath) return;
+50 -19
View File
@@ -1022,6 +1022,18 @@ export class AuthService {
return response.payload || {};
}
async resolveCanonicalDisplayLogin(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return '';
try {
const user = await this.getUser(cleanLogin);
const canonicalLogin = String(user?.login || '').trim();
return canonicalLogin || cleanLogin;
} catch {
return cleanLogin;
}
}
async ensureLoginFree(login) {
const payload = await this.getUser(login);
return payload.exists !== true;
@@ -1127,8 +1139,10 @@ export class AuthService {
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId,
storagePwd,
sessionMaterial: {
@@ -1213,8 +1227,10 @@ export class AuthService {
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId,
storagePwd,
sessionKey: cleanSessionKey,
@@ -1287,8 +1303,10 @@ export class AuthService {
const storagePwd = loginResp?.payload?.storagePwd;
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId: targetSessionId,
storagePwd,
};
@@ -2476,6 +2494,10 @@ export class AuthService {
return response.payload || {};
}
normalizeDmLogin(login) {
return String(login || '').trim().toLowerCase();
}
async buildSignedDmBlock({
signerLogin,
fromLogin,
@@ -2488,9 +2510,9 @@ export class AuthService {
reencryptedAtMs = 0,
bodyBytes = new Uint8Array(0),
}) {
const cleanSignerLogin = String(signerLogin || '').trim();
const cleanFromLogin = String(fromLogin || '').trim();
const cleanToLogin = String(toLogin || '').trim();
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
const cleanToLogin = this.normalizeDmLogin(toLogin);
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
@@ -2590,8 +2612,8 @@ export class AuthService {
revisionTimeMs = 0,
reencryptedAtMs = 0,
}) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
const cleanFromLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanText = String(text || '');
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
@@ -2674,8 +2696,8 @@ export class AuthService {
}
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
@@ -2706,14 +2728,23 @@ export class AuthService {
}
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
const payload = buildReadReceiptPayloadBytes({
refToLogin: cleanRefToLogin,
refFromLogin: cleanRefFromLogin,
refTimeMs,
refNonce,
});
const type3 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
@@ -2721,9 +2752,9 @@ export class AuthService {
bodyBytes: payload,
});
const type4 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
@@ -2737,8 +2768,8 @@ export class AuthService {
}
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
+17 -4
View File
@@ -8,6 +8,10 @@ const DB_VERSION = 1;
const STORE_SECRETS = 'encrypted-secrets';
const STORE_SESSIONS = 'session-keys';
function normalizeLoginStorageKey(login) {
return String(login || '').trim().toLowerCase();
}
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -50,15 +54,20 @@ async function get(storeName, key) {
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SECRETS, {
login,
login: normalizedLogin,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadEncryptedUserSecrets(login, storagePwd) {
const row = await get(STORE_SECRETS, login);
const normalizedLogin = normalizeLoginStorageKey(login);
let row = await get(STORE_SECRETS, normalizedLogin);
if (!row?.encrypted && normalizedLogin !== String(login || '').trim()) {
row = await get(STORE_SECRETS, String(login || '').trim());
}
if (!row?.encrypted) {
throw new Error('На устройстве нет сохранённых ключей для этого логина');
}
@@ -80,15 +89,19 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
}
export async function saveSessionMaterial(login, material) {
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SESSIONS, {
login,
login: normalizedLogin,
...material,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial(login) {
return get(STORE_SESSIONS, login);
const normalizedLogin = normalizeLoginStorageKey(login);
const row = await get(STORE_SESSIONS, normalizedLogin);
if (row || normalizedLogin === String(login || '').trim()) return row;
return get(STORE_SESSIONS, String(login || '').trim());
}
export async function clearClientAuthData() {
@@ -0,0 +1,108 @@
const SNAPSHOT_STORAGE_KEY = 'shine-ui-blockchain-snapshot-v1';
function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
function storageBucketKey(login) {
return normalizeLogin(login) || 'anonymous';
}
function decodeBase64ToBytes(base64) {
const raw = String(base64 || '').trim();
if (!raw) return new Uint8Array();
const bin = atob(raw);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
function readStorageMap() {
try {
const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStorageMap(value) {
try {
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(value || {}));
} catch {
// ignore localStorage errors
}
}
export function readStoredBlockchainSnapshot(login) {
const key = storageBucketKey(login);
const map = readStorageMap();
const value = map?.[key];
return value && typeof value === 'object' ? value : null;
}
export function saveStoredBlockchainSnapshot(login, snapshot) {
const key = storageBucketKey(login);
const map = readStorageMap();
map[key] = {
...snapshot,
savedAtMs: Date.now(),
};
writeStorageMap(map);
return map[key];
}
export function clearStoredBlockchainSnapshot(login) {
const key = storageBucketKey(login);
const map = readStorageMap();
delete map[key];
writeStorageMap(map);
}
export async function buildBlockchainSnapshotFile({
authService,
login,
blockchainName,
lastBlockNumber,
} = {}) {
const cleanLogin = normalizeLogin(login);
const cleanBlockchainName = String(blockchainName || '').trim();
const maxBlockNumber = Number(lastBlockNumber);
if (!authService?.ws?.request) throw new Error('Сервис сервера недоступен.');
if (!cleanLogin) throw new Error('Не указан логин.');
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна.');
if (!Number.isFinite(maxBlockNumber) || maxBlockNumber < 0) {
throw new Error('На сервере нет блоков для слепка.');
}
const parts = [];
let totalBytes = 0;
for (let blockNumber = 0; blockNumber <= maxBlockNumber; blockNumber += 1) {
const response = await authService.ws.request('GetBlockchainBlock', {
blockchainName: cleanBlockchainName,
blockNumber,
});
if (response?.status !== 200) {
const message = String(response?.payload?.message || response?.message || 'Не удалось получить блок.');
throw new Error(`Не удалось скачать блок ${blockNumber}: ${message}`);
}
const blockBytes = decodeBase64ToBytes(response?.payload?.blockBytesB64 || '');
parts.push(blockBytes);
totalBytes += blockBytes.length;
}
const file = new File(parts, `${cleanBlockchainName}.shine-blockchain`, {
type: 'application/octet-stream',
lastModified: Date.now(),
});
return {
file,
blockCount: maxBlockNumber + 1,
totalBytes,
lastBlockNumber: maxBlockNumber,
login: cleanLogin,
blockchainName: cleanBlockchainName,
};
}
+21 -10
View File
@@ -721,6 +721,8 @@ export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
blockchainName: String(bch.blockchainName || ''),
arweaveTxId: String(bch.arweaveTxId || ''),
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
@@ -762,9 +764,10 @@ async function attachSolanaLogs(error, connection) {
}
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const rawLogin = String(login || '').trim();
const cleanLogin = normalizeLogin(login);
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!rawLogin || !cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
@@ -791,6 +794,7 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
return {
rawLogin,
cleanLogin,
endpoint,
solana,
@@ -832,18 +836,19 @@ async function createShineUserPdaOnSolana({
}
const cleanLogin = ctx.cleanLogin;
const displayLogin = ctx.rawLogin;
const cleanPromoCode = String(promoCode || '').trim();
const blockchainName = `${cleanLogin}-001`;
const zeroHash32 = new Uint8Array(32);
const createdAtMs = BigInt(Date.now());
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateBytes = buildLastBlockStateBytes(displayLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
const initialState = createPdaState({
login: cleanLogin,
login: displayLogin,
createdAtMs,
updatedAtMs: createdAtMs,
recordNumber: 0,
@@ -910,7 +915,7 @@ async function createShineUserPdaOnSolana({
});
}
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
login: displayLogin,
recoveryKey32: ctx.recoveryKey32,
rootKey32: ctx.rootKey32,
createdAtMs,
@@ -1028,6 +1033,7 @@ export async function updateShineUserPdaOnSolana({
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
nextArweaveTxId,
serverProfile,
accessServers,
trustedCount,
@@ -1043,6 +1049,7 @@ export async function updateShineUserPdaOnSolana({
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
const effectiveArweaveTxId = nextArweaveTxId == null ? currentBch.arweaveTxId : String(nextArweaveTxId || '').trim();
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
@@ -1060,7 +1067,7 @@ export async function updateShineUserPdaOnSolana({
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
const lastBlockStateBytes = buildLastBlockStateBytes(
cleanLogin,
current.login,
currentBch.blockchainName,
effectiveLastNum,
effectiveLastHash,
@@ -1085,7 +1092,10 @@ export async function updateShineUserPdaOnSolana({
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
// Для prev_hash нужно хэшировать точную unsigned-часть текущей PDA,
// а не пересобирать её из распарсенного состояния: иначе можно получить
// несовпадение байт и InvalidPrevHash в on-chain программе.
const prevHash = await sha256Bytes(current.unsignedBytes || serializeUnsignedRecordFromState(current));
const nextServerProfile = serverProfile
? {
@@ -1097,7 +1107,7 @@ export async function updateShineUserPdaOnSolana({
: current.serverProfile;
const nextState = createPdaState({
login: cleanLogin,
login: current.login,
createdAtMs: current.createdAtMs,
updatedAtMs,
recordNumber: newRecordNumber,
@@ -1113,7 +1123,7 @@ export async function updateShineUserPdaOnSolana({
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
arweaveTxId: effectiveArweaveTxId,
}),
isServer: Boolean(nextServerProfile),
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
@@ -1131,7 +1141,7 @@ export async function updateShineUserPdaOnSolana({
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
login: current.login,
recoveryKey32: current.recoveryKey,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
@@ -1146,7 +1156,7 @@ export async function updateShineUserPdaOnSolana({
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
arweaveTxId: effectiveArweaveTxId,
isServer: nextState.isServer,
addressFormatType: nextState.addressFormatType,
addressFormatVersion: nextState.addressFormatVersion,
@@ -1205,6 +1215,7 @@ export async function updateShineUserPdaOnSolana({
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
arweaveTxId: effectiveArweaveTxId,
};
}
-118
View File
@@ -59,124 +59,6 @@ body::before {
padding: 14px 14px 24px;
}
.screen-swipe-overlay {
--swipe-overlay-opacity: 0.12;
position: absolute;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
z-index: 8;
overflow: hidden;
pointer-events: none;
opacity: 1;
}
.screen-swipe-overlay::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(5, 10, 20, 0.06) 0%, rgba(5, 10, 20, 0.16) 100%),
radial-gradient(circle at center, rgba(89, 165, 255, 0.06) 0%, transparent 70%);
opacity: var(--swipe-overlay-opacity, 0.12);
}
.screen-swipe-pane {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
backface-visibility: hidden;
will-change: transform;
}
.screen-swipe-pane--current {
left: 0;
z-index: 1;
}
.screen-swipe-pane--target {
left: 0;
z-index: 2;
filter: saturate(1.03);
}
.screen-swipe-slot {
position: absolute;
left: 0;
right: 0;
}
.screen-swipe-slot--topbar {
top: 0;
padding: 0 12px;
pointer-events: none;
}
.screen-swipe-slot--topbar > * {
pointer-events: none;
}
.screen-swipe-slot--content {
top: calc(var(--topbar-height, 0px));
bottom: var(--composer-height, 0px);
overflow-y: auto;
padding: 14px 14px 24px;
}
.screen-swipe-slot--composer {
bottom: 0;
padding: 0 12px 8px;
pointer-events: none;
}
.screen-swipe-slot--composer > * {
pointer-events: none;
}
.screen-swipe-pane--target::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(8, 14, 28, 0.05) 0%, rgba(8, 14, 28, 0.12) 100%);
pointer-events: none;
}
.screen-swipe-pane--left {
box-shadow: -24px 0 34px rgba(3, 7, 18, 0.34);
}
.screen-swipe-pane--right {
box-shadow: 24px 0 34px rgba(3, 7, 18, 0.34);
}
.screen-swipe-divider {
position: absolute;
top: 0;
bottom: 0;
left: -1px;
width: 2px;
z-index: 3;
background:
linear-gradient(180deg, rgba(248, 251, 255, 0.68) 0%, rgba(137, 186, 255, 0.52) 48%, rgba(53, 90, 144, 0.34) 100%);
box-shadow:
0 0 0 1px rgba(14, 24, 42, 0.08),
0 0 18px rgba(80, 154, 255, 0.22);
pointer-events: none;
}
.app-shell--swiping .toolbar-slot {
pointer-events: none;
}
.topbar-slot--swipe-hidden,
.screen-content--swipe-hidden,
.composer-slot--swipe-hidden {
opacity: 0;
}
.screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0;