SHA256
Синхронизировать регистр логинов и доработать UI каналов
This commit is contained in:
+6
-473
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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?.() },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user