Merge origin/main (+56: DM v1 E2EE, промокоды, recovery key) + локальные фичи

Слито и разрешено вручную:
- chat-view.js: база — новый DM-чат агента (ответы/реплаи, ревизии, клавиатурный
  UX, голосовой ввод); поверх встроен эмодзи-пикер (Telegram-набор): кнопка ☺,
  вставка в позицию курсора, анимированные эмодзи в пузырях (renderMessageText
  поверх displayText), остановка анимаций в cleanup.
- register-view.js: база — версия агента (промокоды, глаз пароля, TEMP-заглушка
  коротких логинов); возвращена компактная ссылка «Вопросы о регистрации»
  (FAQ-экран у агента осиротел — снова достижим).
- components.css: шапка «Связей» — вариант агента (sticky + grid minmax,
  заголовок сокращается, кнопки не обрезаются); стили эмодзи-пикера сохранены.
- Заметки Pending_Features (шапка связей, поиск каналов, локальный вход)
  переложены в новую структуру docs/Pending_Features.
- VERSION: client 1.2.314, server 1.2.292 (продолжение нумерации origin).

Проверено локально: старт → «Открыть локальный тестовый режим» → Личные →
чат: эмодзи-пикер открывается, эмодзи вставляется, PNG-ассеты грузятся;
регистрация: промокод/12 слов/FAQ-ссылка работают; консоль чистая.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-07-16 11:41:41 +03:00
co-authored by Claude Opus 4.8
374 changed files with 18484 additions and 3887 deletions
+289 -20
View File
@@ -15,6 +15,7 @@ import {
startDebugConnectionAsInitiator,
startDebugConnectionAsResponder,
} from './services/call-service.js';
import { parseDmTechBlocks } from './services/dm-tech-blocks.js';
import {
authService,
addAppLogEntry,
@@ -27,6 +28,8 @@ import {
state,
terminateCurrentSession,
addSignedMessageToChat,
deleteConversationMessagesBefore,
deleteSignedMessageByBaseKey,
markIncomingReadByBaseKey,
markOutgoingReadByBaseKey,
normalizeDmChatId,
@@ -47,14 +50,16 @@ import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js';
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
import * as profileView from './pages/profile-view.js?v=202607150910';
import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js?v=202605300007';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
@@ -79,6 +84,18 @@ import * as addPersonalPublicChatView from './pages/add-personal-public-chat-vie
import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
const DM_DECRYPT_FAILED_TEXT = 'Не удалось расшифровать сообщение';
const DM_CORRUPTED_TEXT = 'Сообщение повреждено';
function dmDecryptFallbackText(error) {
const kind = authService.classifyDmDecryptFailure(error);
if (kind === 'unsupported_format') return DM_UNSUPPORTED_FORMAT_TEXT;
if (kind === 'corrupted') return DM_CORRUPTED_TEXT;
return DM_DECRYPT_FAILED_TEXT;
}
const routes = {
'start-view': startView,
'entry-settings-view': entrySettingsView,
@@ -94,6 +111,7 @@ const routes = {
'login-other-device-view': loginOtherDeviceView,
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
queue: publicSupportQueueView,
'profile-view': profileView,
'profile-edit-view': profileEditView,
'wallet-view': walletView,
@@ -101,6 +119,7 @@ const routes = {
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'tools-settings-view': toolsSettingsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-pairing-view': clientPairingView,
@@ -131,6 +150,8 @@ const toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
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*'([^']+)'/;
@@ -150,6 +171,8 @@ let uiUpdateReloadScheduled = false;
let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
@@ -160,6 +183,7 @@ const GUEST_ALLOWED_PAGES = new Set([
'channel-thread-view',
'user',
'contact-search-view',
'queue',
]);
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
@@ -172,6 +196,94 @@ initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
async function unlockHiddenDmAudio() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return false;
if (!hiddenDmAudioContext) {
hiddenDmAudioContext = new Ctx();
}
if (hiddenDmAudioContext.state === 'suspended') {
await hiddenDmAudioContext.resume();
}
hiddenDmAudioUnlocked = hiddenDmAudioContext.state === 'running';
return hiddenDmAudioUnlocked;
} catch {
return false;
}
}
async function playDmSignal({ extended = false } = {}) {
try {
if (!hiddenDmAudioUnlocked || !hiddenDmAudioContext) return false;
if (hiddenDmAudioContext.state === 'suspended') {
await hiddenDmAudioContext.resume();
}
if (hiddenDmAudioContext.state !== 'running') return false;
const now = hiddenDmAudioContext.currentTime;
const gain = hiddenDmAudioContext.createGain();
gain.connect(hiddenDmAudioContext.destination);
gain.gain.setValueAtTime(0.0001, now);
const pulse = (offsetSec, freqHz, durationSec, peakGain) => {
const osc = hiddenDmAudioContext.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freqHz, now + offsetSec);
osc.connect(gain);
gain.gain.exponentialRampToValueAtTime(peakGain, now + offsetSec + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + offsetSec + durationSec);
osc.start(now + offsetSec);
osc.stop(now + offsetSec + durationSec + 0.02);
};
if (extended) {
pulse(0, 880, 0.18, 0.032);
pulse(0.24, 1174, 0.2, 0.026);
pulse(0.52, 1567, 0.24, 0.02);
} else {
pulse(0, 1046, 0.12, 0.028);
pulse(0.17, 1318, 0.14, 0.022);
}
return true;
} catch {
return false;
}
}
async function notifyHiddenIncomingMessage(fromLogin, text) {
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
const title = `Сообщение от ${fromLogin}`;
try {
const registration = await navigator.serviceWorker?.getRegistration?.();
if (registration?.showNotification) {
await registration.showNotification(title, {
body,
tag: `shine-hidden-dm-${String(fromLogin || '').trim().toLowerCase() || 'unknown'}`,
renotify: true,
data: {
kind: 'new_message',
fromLogin,
},
});
} else {
new Notification(title, { body });
}
} catch {
// ignore notification errors
}
try {
if (typeof navigator.vibrate === 'function') {
navigator.vibrate([140, 80, 220, 90, 180]);
}
} catch {
// ignore vibration errors
}
void playDmSignal({ extended: true });
}
function ensureConnectionIndicatorEl() {
return document.getElementById('toolbar-connection-indicator');
}
@@ -555,6 +667,44 @@ function wsIsOpen() {
return !!(ws && ws.readyState === WebSocket.OPEN);
}
function sleep(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
async function waitForSignedDmDecryptContext(timeoutMs = SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS) {
const startedAtMs = Date.now();
while ((Date.now() - startedAtMs) < timeoutMs) {
const login = String(state.session?.login || '').trim();
const storagePwd = String(state.session?.storagePwdInMemory || '').trim();
if (login && storagePwd) {
return { login, storagePwd };
}
await sleep(SIGNED_DM_DECRYPT_CONTEXT_POLL_MS);
}
return {
login: String(state.session?.login || '').trim(),
storagePwd: String(state.session?.storagePwdInMemory || '').trim(),
};
}
function resolveSignedDmChatId({ parsed, login }) {
const parsedBlock = parsed || {};
const messageType = Number(parsedBlock.messageType || 0);
const fromLogin = String(parsedBlock.fromLogin || '').trim();
const toLogin = String(parsedBlock.toLogin || '').trim();
const currentLogin = String(login || '').trim().toLowerCase();
if (messageType === 1) return normalizeDmChatId(fromLogin);
if (messageType === 2) return normalizeDmChatId(toLogin);
if (currentLogin && currentLogin === fromLogin.toLowerCase()) {
return normalizeDmChatId(toLogin);
}
return normalizeDmChatId(fromLogin);
}
async function ensureSessionAfterWsReconnect() {
if (!state.session.isAuthorized) return true;
if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight;
@@ -754,6 +904,28 @@ function renderApp() {
}
}
function refreshToolbarOnly() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
refreshConnectionUi();
}
function refreshUnreadUi() {
const pageId = getRoute().pageId || '';
if (pageId === 'messages-list') {
renderApp();
return;
}
refreshToolbarOnly();
}
async function tryAutoLogin() {
if (state.session.isLocalDemo) return;
if (!state.session.login || !state.session.sessionId) return;
@@ -928,10 +1100,48 @@ async function init() {
const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || '';
const messageType = Number(parsed.messageType || 0);
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
const text = (messageType === 1 || messageType === 2)
? String(parsed.text || '')
: '';
let shouldAckSessionDelivery = true;
let text = '';
let sessionLoginForRouting = String(state.session?.login || '').trim();
if (messageType === 1 || messageType === 2) {
const decryptContext = await waitForSignedDmDecryptContext();
if (!decryptContext.login || !decryptContext.storagePwd) {
shouldAckSessionDelivery = false;
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Отложена обработка DM: decrypt-контекст сессии ещё не готов',
details: { messageKey, baseKey: parsed.baseKey, messageType },
});
return;
}
sessionLoginForRouting = decryptContext.login;
try {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: decryptContext.login,
storagePwd: decryptContext.storagePwd,
});
text = String(decrypted?.text || '');
} catch (error) {
text = dmDecryptFallbackText(error);
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось расшифровать DM',
details: {
messageKey,
baseKey: parsed.baseKey,
error: error?.message || 'unknown',
fallbackText: text,
},
});
}
}
const chatId = resolveSignedDmChatId({
parsed,
login: sessionLoginForRouting,
});
let shouldRefreshToolbarUnread = false;
@@ -962,16 +1172,24 @@ async function init() {
if (added && isIncomingForCurrent) {
shouldRefreshToolbarUnread = true;
}
if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
if (added && isIncomingForCurrent && !payload.backlog) {
const parsedText = parseDmTechBlocks(text);
const displayText = String(parsedText.displayText || '');
if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') {
try {
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
} catch {}
} else {
void playDmSignal({ extended: true });
}
}
} else if (messageType === 3 || messageType === 4) {
let refBaseKey = String(payload.receiptRefBaseKey || '').trim();
if (!refBaseKey) {
try {
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
const ref = parsed.readReceiptPayload || authService.parseReadReceiptPayload(parsed.payloadBytes);
refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
} catch {}
}
@@ -979,7 +1197,9 @@ async function init() {
if (messageType === 3) {
markOutgoingReadByBaseKey(refBaseKey);
} else {
markIncomingReadByBaseKey(refBaseKey);
if (markIncomingReadByBaseKey(refBaseKey)) {
shouldRefreshToolbarUnread = true;
}
}
}
addAppLogEntry({
@@ -988,21 +1208,62 @@ async function init() {
message: 'Получено подтверждение прочтения',
details: { messageKey, baseKey: parsed.baseKey, messageType },
});
}
try {
await authService.ackSessionDelivery(messageKey);
} catch (error) {
} else if (messageType === 5 || messageType === 6) {
const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey);
if (changed) shouldRefreshToolbarUnread = true;
addAppLogEntry({
level: 'warn',
level: 'info',
source: 'signed-dm',
message: 'Не удалось отправить ACK доставки по сессии',
details: { messageKey, error: error?.message || 'unknown' },
message: 'Получено удаление сообщения',
details: { messageKey, baseKey: parsed.baseKey, messageType, changed },
});
} else if (messageType === 7 || messageType === 8) {
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
addSignedMessageToChat({
chatId,
messageKey,
baseKey: parsed.baseKey,
from: messageType === 8 ? 'out' : 'in',
text: CONVERSATION_CLEAR_NOTICE_TEXT,
messageType,
unread: false,
rawBlobB64: blobB64,
});
if (removed > 0) shouldRefreshToolbarUnread = true;
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: 'Получено удаление истории переписки',
details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) },
});
}
if (shouldAckSessionDelivery) {
try {
await authService.ackSessionDelivery(messageKey);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось отправить ACK доставки по сессии',
details: { messageKey, error: error?.message || 'unknown' },
});
}
}
const pageId = getRoute().pageId || '';
if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) {
if (pageId === 'chat-view') {
window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', {
detail: {
chatId,
messageType,
messageKey,
},
}));
if (shouldRefreshToolbarUnread) {
refreshToolbarOnly();
}
} else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) {
renderApp();
}
});
@@ -1011,6 +1272,8 @@ async function init() {
try { await handleIncomingCallInvite(evt); } catch {}
});
window.addEventListener('shine-unread-state-updated', refreshUnreadUi);
authService.onEvent('IncomingCallSignal', async (evt) => {
try { await handleIncomingCallSignal(evt); } catch {}
});
@@ -1099,6 +1362,12 @@ async function init() {
})();
window.addEventListener('popstate', renderApp);
document.addEventListener('pointerdown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
document.addEventListener('keydown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
void checkConnectionHealth();
+3 -1
View File
@@ -26,8 +26,10 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
right.className = 'header-actions';
rightActions.forEach((action) => {
const btn = document.createElement('button');
btn.className = 'icon-btn';
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
btn.textContent = action.label;
if (action.title) btn.title = action.title;
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
btn.addEventListener('click', action.onClick);
right.append(btn);
});
+2
View File
@@ -5,3 +5,5 @@ export const defaultServerLogin = 'shineupme';
export const defaultServerAddress = 'shineup.me';
export const defaultServerHttp = `https://${defaultServerAddress}`;
export const defaultServerWs = `wss://${defaultServerAddress}/ws`;
export const defaultSolanaCluster = 'mainnet-beta';
export const defaultSolanaEndpoint = 'https://solana-rpc.publicnode.com';
+629 -71
View File
@@ -6,6 +6,7 @@ import {
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
deleteConversationMessagesBefore,
getChatMessages,
markChatRead,
markOutgoingSent,
@@ -23,10 +24,13 @@ import {
isTelegramAnimatedEmoji,
stopAllTelegramEmojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
@@ -35,6 +39,12 @@ function truncatePreviewText(value, maxLen = 72) {
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}`;
}
function findMessageByBaseKey(messages, baseKey) {
const normalized = String(baseKey || '').trim();
if (!normalized) return null;
return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null;
}
function openDeleteMessageConfirmModal({ onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -62,12 +72,83 @@ function openDeleteMessageConfirmModal({ onConfirm }) {
});
}
function openChatConfirmModal({
title = 'Подтвердить действие',
text = '',
confirmLabel = 'Да',
confirmKind = 'primary',
onConfirm,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn';
root.innerHTML = `
<div class="modal" id="chat-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">${title}</h3>
<p class="meta-muted">${text}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-confirm-no">Нет</button>
<button class="${confirmClass}" type="button" id="chat-confirm-yes">${confirmLabel}</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-confirm-no')?.addEventListener('click', close);
root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => {
close();
if (typeof onConfirm === 'function') await onConfirm();
});
}
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-delete-chat-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить чат?</h3>
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory });
}
});
}
function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
canDelete = false,
onReply,
onReadAloud,
onEdit,
onDelete,
@@ -79,8 +160,9 @@ function openMessageActionsMenu({
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
</div>
@@ -137,6 +219,11 @@ function openMessageActionsMenu({
}
});
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
close();
if (typeof onReply === 'function') await onReply();
});
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
close();
if (typeof onReadAloud === 'function') await onReadAloud();
@@ -153,6 +240,78 @@ function openMessageActionsMenu({
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
</div>
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
});
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
close();
if (typeof onDeleteChat === 'function') await onDeleteChat();
});
}
function showTtsMissingConfigDialog(navigate) {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -254,6 +413,31 @@ function formatMessageTime(valueMs) {
}).format(new Date(timeMs));
}
function formatCallDurationCompact(totalSeconds) {
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function buildCallStatusText(callSummary) {
if (!callSummary) return '';
if (callSummary.status === 'completed') {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Не дозвонился: абонент занят';
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
return 'Не дозвонился';
}
function resolveDeliveryStatus(msg) {
if (msg?.from !== 'out') return '';
if (msg?.secondTick) return '✓✓';
@@ -269,8 +453,13 @@ function resolveMessageEditedTimeMs(msg) {
function scrollToLatestMessage(list) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const apply = () => {
list.scrollTop = list.scrollHeight;
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' });
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
};
apply();
window.requestAnimationFrame(apply);
@@ -294,7 +483,51 @@ function renderMessageText(textNode, messageText) {
}
}
function renderLog(list, chatId, { onOpenActions } = {}) {
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop);
const useSmooth = smoothIfNearBottom && distanceToBottom <= 180;
const apply = (behavior = useSmooth ? 'smooth' : 'auto') => {
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior });
}
if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
apply(useSmooth ? 'smooth' : 'auto');
window.requestAnimationFrame(() => apply('auto'));
window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90);
}
function scrollToUnreadSeparator(list) {
if (!list) return false;
const separator = list.querySelector('.chat-unread-separator');
if (!separator) return false;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const apply = () => {
if (separator?.scrollIntoView) {
separator.scrollIntoView({ block: 'start', inline: 'nearest' });
}
const bottomSlack = 72;
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack);
};
apply();
window.requestAnimationFrame(apply);
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
window.setTimeout(apply, 60);
window.setTimeout(apply, 160);
return true;
}
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
@@ -310,13 +543,70 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
}
const bubble = document.createElement('div');
const bubbleKind = String(msg?.kind || '').trim();
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
if (parsedText.callSummary) {
const callCard = document.createElement('div');
callCard.className = 'bubble-call-card';
const callHead = document.createElement('div');
callHead.className = 'bubble-call-head';
const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon';
callIcon.textContent = '☎';
const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title';
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
callHead.append(callIcon, callTitle);
const callMetaLine = document.createElement('div');
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus;
callCard.append(callHead, callMetaLine);
bubble.append(callCard);
}
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
renderMessageText(textNode, msg.text);
bubble.append(textNode);
renderMessageText(textNode, parsedText.displayText || '');
if (parsedText.replyRef) {
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
if (replyTarget) {
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
const replyText = document.createElement('div');
replyText.className = 'bubble-reply-preview-text';
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
replyBox.append(replyAuthor, replyText);
replyBox.addEventListener('click', (event) => {
event.stopPropagation();
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
if (targetNode?.scrollIntoView) {
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
}
});
bubble.append(replyBox);
}
}
if (!parsedText.callSummary) {
bubble.append(textNode);
}
const metaNode = document.createElement('div');
metaNode.className = 'bubble-meta';
@@ -347,10 +637,46 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
bubble.addEventListener('click', (event) => {
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
});
bubble.dataset.baseKey = String(msg?.baseKey || '');
list.append(bubble);
});
scrollToLatestMessage(list);
markChatRead(chatId);
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
scrollToLatestMessage(list);
} else if (scrollMode === 'latest') {
scrollToLatestMessage(list);
}
if (markAsRead) {
markChatRead(chatId);
}
}
function preserveComposerSelection(input, callback) {
if (!input || typeof callback !== 'function') {
if (typeof callback === 'function') callback();
return;
}
const wasFocused = document.activeElement === input;
const start = Number(input.selectionStart ?? input.value.length);
const end = Number(input.selectionEnd ?? input.value.length);
callback();
if (!wasFocused) return;
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
try {
input.setSelectionRange(start, end);
} catch {
// ignore
}
}
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
}
export function render({ navigate, route }) {
@@ -364,14 +690,58 @@ export function render({ navigate, route }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen';
const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
};
const handleStartCall = async () => {
try {
await startOutgoingCall(chatId);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
};
const clearConversationHistory = async () => {
const result = await authService.deleteConversation({
login: state.session.login,
toLogin: chatId,
storagePwd: state.session.storagePwdInMemory,
deleteByRecipient: true,
});
const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim());
deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0));
addSignedMessageToChat({
chatId,
messageKey: String(parsed?.messageKey || ''),
baseKey: String(parsed?.baseKey || ''),
from: 'out',
text: CONVERSATION_CLEAR_NOTICE_TEXT,
messageType: Number(parsed?.messageType || 8),
unread: false,
rawBlobB64: String(result?.localBlobB64 || ''),
});
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
notifyUnreadStateUpdated();
};
const wrap = document.createElement('div');
@@ -384,21 +754,70 @@ export function render({ navigate, route }) {
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: async () => {
try {
await startOutgoingCall(chatId);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
rightActions: [
{
label: '☎',
title: 'Позвонить',
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
onClick: handleStartCall,
},
}],
{
label: '⋯',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall,
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
confirmKind: 'danger',
onConfirm: async () => {
try {
await clearConversationHistory();
showToast('История чата очищена', { timeoutMs: 1200 });
} catch (error) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
onDeleteChat: async () => {
openDeleteChatConfirmModal({
contactName: contact.name,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
}
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
notifyUnreadStateUpdated();
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
});
},
},
],
})
);
@@ -452,6 +871,7 @@ export function render({ navigate, route }) {
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи">☺</button>
${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
`;
@@ -463,8 +883,20 @@ export function render({ navigate, route }) {
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
let activeEdit = null;
let activeReply = null;
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const syncKeyboardUi = () => {
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
};
const rememberEmojiSelection = () => {
if (!input) return;
@@ -505,13 +937,17 @@ export function render({ navigate, route }) {
const syncEditBanner = () => {
if (!editBanner || !editBannerText) return;
if (!activeEdit) {
if (!activeEdit && !activeReply) {
editBanner.hidden = true;
editBannerText.textContent = '';
return;
}
editBanner.hidden = false;
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
if (activeEdit) {
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
return;
}
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
};
const focusInputToEnd = () => {
@@ -537,46 +973,91 @@ export function render({ navigate, route }) {
}
};
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
if (!activeReply) return;
activeReply = null;
syncEditBanner();
if (restoreDraft && input) {
autoResizeComposer(input);
focusInputToEnd();
}
};
const startEditMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base || msg?.from !== 'out') return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const currentDraft = String(input?.value || '');
activeReply = null;
activeEdit = {
messageKey: String(msg?.messageKey || ''),
originalText: String(msg?.text || ''),
originalText: String(parsed?.displayText || ''),
prefixText: String(parsed?.prefixText || ''),
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
timeMs: base.timeMs,
nonce: base.nonce,
};
syncEditBanner();
if (input) {
input.value = String(msg?.text || '');
input.value = String(parsed?.visibleText || '');
autoResizeComposer(input);
focusInputToEnd();
}
};
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
const startReplyMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
activeEdit = null;
activeReply = {
baseKey: String(msg?.baseKey || ''),
fromLogin: base.fromLogin,
toLogin: base.toLogin,
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
};
syncEditBanner();
focusInputToEnd();
};
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
if (!localOutgoingBlobB64) return;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
let text = '';
if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
text = String(decrypted?.text || '');
}
addSignedMessageToChat({
chatId,
messageKey: fallbackMessageKey || parsed?.messageKey || '',
baseKey: fallbackBaseKey || parsed?.baseKey || '',
from: 'out',
text: parsed?.text || '',
text,
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deleted: Boolean(parsed?.deleted),
});
return true;
} catch {
// ignore local parse failure; server backlog/realtime will reconcile later
return false;
}
};
const notifyUnreadStateUpdated = () => {
window.dispatchEvent(new CustomEvent('shine-unread-state-updated', {
detail: { chatId },
}));
};
const sendDeleteRevision = async (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
@@ -587,11 +1068,13 @@ export function render({ navigate, route }) {
timeMs: base.timeMs,
nonce: base.nonce,
revisionTimeMs: Date.now(),
deleteByRecipient: msg?.from === 'in',
});
applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
addSignedMessageToChat({
chatId,
messageKey: String(msg?.messageKey || ''),
baseKey: String(msg?.baseKey || ''),
deleted: true,
});
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true });
@@ -600,11 +1083,17 @@ export function render({ navigate, route }) {
};
const sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim();
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
let result;
@@ -612,7 +1101,7 @@ export function render({ navigate, route }) {
result = await authService.sendDirectMessageRevision({
login: state.session.login,
toLogin: chatId,
text,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
timeMs: editing.timeMs,
nonce: editing.nonce,
@@ -622,7 +1111,7 @@ export function render({ navigate, route }) {
result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
@@ -631,7 +1120,7 @@ export function render({ navigate, route }) {
});
}
applyLocalRevision({
const localRevisionApplied = await applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
@@ -639,13 +1128,24 @@ export function render({ navigate, route }) {
if (editing) {
cancelEditMode({ restoreDraft: true });
} else if (replying) {
cancelReplyMode({ restoreDraft: false });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessage(log);
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 90);
window.setTimeout(() => scrollToLatestMessage(log), 220);
if (localRevisionApplied) {
notifyUnreadStateUpdated();
}
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220);
if (input) {
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
}
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
@@ -655,6 +1155,7 @@ export function render({ navigate, route }) {
messageId: result?.outgoingKey || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (e) {
@@ -678,12 +1179,21 @@ export function render({ navigate, route }) {
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: msg?.text || '',
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
messageText: parsed?.displayText || msg?.text || '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
onReply: async () => {
startReplyMode(msg);
},
onReadAloud: async () => handleReadAloud(msg),
onEdit: async () => {
startEditMode(msg);
@@ -703,7 +1213,11 @@ export function render({ navigate, route }) {
};
editCancelBtn?.addEventListener('click', () => {
cancelEditMode({ restoreDraft: true });
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
return;
}
cancelReplyMode({ restoreDraft: true });
});
autoResizeComposer(input);
@@ -716,8 +1230,14 @@ export function render({ navigate, route }) {
input?.addEventListener('click', rememberEmojiSelection);
input?.addEventListener('focus', () => {
rememberEmojiSelection();
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log);
});
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection();
@@ -734,28 +1254,31 @@ export function render({ navigate, route }) {
});
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.ctrlKey) {
event.preventDefault();
const start = Number(input.selectionStart ?? input.value.length);
const end = Number(input.selectionEnd ?? input.value.length);
const value = String(input.value || '');
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
const nextPos = start + 1;
try {
input.setSelectionRange(nextPos, nextPos);
} catch {
// ignore
}
autoResizeComposer(input);
return;
}
window.requestAnimationFrame(() => autoResizeComposer(input));
});
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(input.value || '').trim();
input.value = prev ? `${prev} ${text}` : text;
autoResizeComposer(input);
},
onSendText: async (text) => sendTextMessage(text),
onSendQueued: () => {
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
},
});
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
closeEmojiPicker();
await sendTextMessage(text);
try {
input?.focus({ preventScroll: true });
} catch {
input?.focus();
}
});
form.addEventListener('submit', async (event) => {
@@ -766,14 +1289,50 @@ export function render({ navigate, route }) {
autoResizeComposer(input);
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return;
preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
});
window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId);
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
wrap.append(log, form);
screen.append(wrap);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
});
if (hasUnreadIncoming) {
window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
window.setTimeout(() => scrollToUnreadSeparator(log), 180);
} else {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
}
window.setTimeout(() => {
if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated();
}
}, 220);
void sendReadReceiptsForVisible(chatId);
screen.cleanup = () => {
setChatKeyboardOpen(false);
stopAllTelegramEmojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
};
return screen;
}
@@ -793,7 +1352,6 @@ async function sendReadReceiptsForVisible(chatId) {
refFromLogin: ref.fromLogin,
refTimeMs: ref.timeMs,
refNonce: ref.nonce,
refType: 1,
});
if (row.baseKey) {
markReadReceiptSentByBaseKey(row.baseKey);
+1 -1
View File
@@ -205,7 +205,7 @@ export function render({ navigate }) {
<span class="field-label">Активные заявки</span>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
+5 -8
View File
@@ -1,9 +1,9 @@
import { renderHeader } from '../components/header.js';
import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js';
import { state } from '../state.js';
export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false };
const DEVNET_ENDPOINT = 'https://api.devnet.solana.com';
const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu';
const TRANSFER_AMOUNT_SOL = 0.02;
@@ -49,7 +49,7 @@ export function render() {
status.style.overflowWrap = 'anywhere';
status.style.wordBreak = 'break-word';
status.style.whiteSpace = 'pre-wrap';
status.textContent = 'Готово к пополнению.';
status.textContent = `Готово к пополнению.\nСеть: DEVNET (${DEVNET_ENDPOINT})`;
const amountHint = document.createElement('p');
amountHint.style.width = 'min(100%, 320px)';
@@ -99,16 +99,14 @@ export function render() {
const updateSenderBalance = async () => {
if (!senderAddress) return;
const endpoint = state.entrySettings.solanaServer;
const balance = await getBalanceSol({ endpoint, address: senderAddress });
const balance = await getBalanceSol({ endpoint: DEVNET_ENDPOINT, address: senderAddress });
const senderBalanceEl = senderBox.querySelector('#devnet-topup-sender-balance');
if (senderBalanceEl) senderBalanceEl.textContent = `Баланс: ${formatSol(balance.sol, 6)} SOL`;
};
const updateTargetBalance = async () => {
if (!targetWallet) return null;
const endpoint = state.entrySettings.solanaServer;
const balance = await getBalanceSol({ endpoint, address: targetWallet });
const balance = await getBalanceSol({ endpoint: DEVNET_ENDPOINT, address: targetWallet });
const targetBalanceEl = targetBox.querySelector('#devnet-topup-target-balance');
if (targetBalanceEl) targetBalanceEl.textContent = `Баланс: ${formatSol(balance.sol, 6)} SOL`;
return balance.sol;
@@ -131,9 +129,8 @@ export function render() {
closeHint.style.display = 'none';
try {
const endpoint = state.entrySettings.solanaServer;
const tx = await transferSol({
endpoint,
endpoint: DEVNET_ENDPOINT,
fromKeypair: senderKeypair,
toAddress: targetWallet,
amountSol: TRANSFER_AMOUNT_SOL,
+4
View File
@@ -35,16 +35,20 @@ export function render({ navigate }) {
card.innerHTML = `
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
<p class="meta-muted key-storage-option__description">Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.</p>
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<p class="meta-muted key-storage-option__description">Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.</p>
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
</div>
<p class="key-storage-note key-storage-note--strong">Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.</p>
`;
card.children[0].querySelector('label').prepend(rootToggle);
+19 -4
View File
@@ -10,6 +10,7 @@ import {
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const dmAvatarSnapshotCache = new Map();
@@ -64,6 +65,12 @@ function createDmAvatar(login) {
return avatarEl;
}
function resolveLastMessagePreview(text = '') {
const parsed = parseDmTechBlocks(String(text || ''));
const display = String(parsed.displayText || '').trim();
return display || '';
}
function formatChatRowTime(ts) {
const value = Number(ts || 0);
if (!Number.isFinite(value) || value <= 0) return '';
@@ -88,12 +95,14 @@ export function render({ navigate }) {
<div class="dm-head-brand">
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name">${login}</span>
<span class="dm-head-name"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const divider = document.createElement('div');
@@ -113,19 +122,25 @@ export function render({ navigate }) {
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title">${item.name}</strong>
<strong class="dm-row-title"></strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
</div>
<p class="dm-row-last-message">${item.lastMessage}</p>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line">
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
</div>
`;
const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.name || '');
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
if (timeEl) timeEl.textContent = String(item.time || '');
row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
return row;
-11
View File
@@ -268,16 +268,6 @@ export function render({ navigate, route }) {
return makeProfileRoute(cleanLogin);
}
function helpText() {
return [
'Обозначения на экране связей:',
'• Синие линии — близкие друзья.',
'• Оранжевые линии — родственники.',
'• Стрелка на линии — односторонняя связь.',
'• Если стрелки нет, связь взаимная.',
].join('\n');
}
function persistHistory() {
persistedCenterLogin = centerLogin;
persistedCenterHistory = [...centerHistory];
@@ -523,7 +513,6 @@ export function render({ navigate, route }) {
},
rightActions: [
{ label: 'Найти', onClick: openSearchModal },
{ label: '?', onClick: () => window.alert(helpText()) },
],
});
const backBtnEl = header.querySelector('.header-left .icon-btn');
@@ -0,0 +1,369 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
export const pageMeta = { id: 'queue', title: 'Очередь билета' };
const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
const SHINE_PAYMENTS_SEEDS = {
config: 'shine_payments_config',
coef: 'shine_payments_coef_limit',
queues: 'shine_payments_queues',
q1: 'shine_payments_q1_ticket',
q2: 'shine_payments_q2_ticket',
q3: 'shine_payments_q3_ticket',
};
function utf8Bytes(text) {
return new TextEncoder().encode(String(text || ''));
}
function u64ToBytes(value) {
const out = new Uint8Array(8);
let current = BigInt(value || 0);
for (let i = 0; i < 8; i += 1) {
out[i] = Number(current & 0xffn);
current >>= 8n;
}
return out;
}
function readU64(data, offset) {
let value = 0n;
for (let i = 0; i < 8; i += 1) {
value |= BigInt(data[offset + i]) << (8n * BigInt(i));
}
return value;
}
function readI64(data, offset) {
let value = readU64(data, offset);
if (value > 0x7fffffffffffffffn) {
value -= 0x10000000000000000n;
}
return value;
}
function readI32(data, offset) {
let value = Number(readU64(data, offset) & 0xffffffffn);
if (value > 0x7fffffff) value -= 0x100000000;
return value;
}
function formatQueuePrefix(queueId) {
if (queueId === 2) return 'Q2';
if (queueId === 3) return 'Q3';
return 'Q1';
}
function formatUsdCentsText(cents) {
const value = Number(cents || 0n) / 100;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
function parsePaymentsCoef(data) {
let offset = 0;
const version = data[offset];
offset += 1;
const coefPpm = readU64(data, offset);
offset += 8;
const limitUsdCents = readU64(data, offset);
offset += 8;
const callRewardLamports = readU64(data, offset);
return { version, coefPpm, limitUsdCents, callRewardLamports };
}
function parsePaymentsQueues(data) {
let offset = 0;
const version = data[offset];
offset += 1;
const q1TicketsTotal = readU64(data, offset); offset += 8;
const q1TicketsPaid = readU64(data, offset); offset += 8;
const q1SumTotalUsdCents = readU64(data, offset); offset += 8;
const q1SumPaidUsdCents = readU64(data, offset); offset += 8;
const q2TicketsTotal = readU64(data, offset); offset += 8;
const q2TicketsPaid = readU64(data, offset); offset += 8;
const q2SumTotalUsdCents = readU64(data, offset); offset += 8;
const q2SumPaidUsdCents = readU64(data, offset); offset += 8;
const q3TicketsTotal = readU64(data, offset); offset += 8;
const q3TicketsPaid = readU64(data, offset); offset += 8;
const q3SumTotalUsdCents = readU64(data, offset); offset += 8;
const q3SumPaidUsdCents = readU64(data, offset);
return {
version,
q1TicketsTotal,
q1TicketsPaid,
q1SumTotalUsdCents,
q1SumPaidUsdCents,
q2TicketsTotal,
q2TicketsPaid,
q2SumTotalUsdCents,
q2SumPaidUsdCents,
q3TicketsTotal,
q3TicketsPaid,
q3SumTotalUsdCents,
q3SumPaidUsdCents,
};
}
function parsePaymentsTicket(data) {
let offset = 0;
const version = data[offset];
offset += 1;
const queueId = data[offset];
offset += 1;
const index = readU64(data, offset);
offset += 8;
const isPaid = Boolean(data[offset]);
offset += 1;
const solana = window.solanaWeb3;
const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58();
offset += 32;
const payoutUsdCents = readU64(data, offset);
offset += 8;
const debtBeforeUsdCents = readU64(data, offset);
return {
version,
queueId,
index,
isPaid,
recipientWallet,
payoutUsdCents,
debtBeforeUsdCents,
};
}
function parseSupportTicketInput(rawValue) {
const clean = String(rawValue || '').trim();
if (!clean) throw new Error('Введите номер билета.');
if (/^\d+$/.test(clean)) {
return { queueId: 1, index: BigInt(clean) };
}
const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
if (!match) {
throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
}
return {
queueId: Number(match[1]),
index: BigInt(match[2]),
};
}
async function loadSupportPaymentsCore(endpoint) {
const solana = await loadSolanaWeb3();
const rpc = String(endpoint || '').trim();
const connection = new solana.Connection(rpc, 'confirmed');
const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT);
const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId);
const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId);
const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId);
const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([
connection.getAccountInfo(oracleAccount, 'confirmed'),
connection.getAccountInfo(configPda, 'confirmed'),
connection.getAccountInfo(coefPda, 'confirmed'),
connection.getAccountInfo(queuesPda, 'confirmed'),
]);
if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.');
if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.');
const parsePrice = (data) => {
const price = readI64(data, 73);
const exponent = readI32(data, 89);
const publishTime = readI64(data, 93);
if (price <= 0n) throw new Error('Оракул вернул некорректную цену.');
let num = price * 100n;
let den = 1n;
if (exponent >= 0) {
num *= 10n ** BigInt(exponent);
} else {
den *= 10n ** BigInt(-exponent);
}
return { priceNum: num, priceDen: den, publishTime };
};
return {
connection,
programId,
coef: parsePaymentsCoef(coefAi.data),
queues: parsePaymentsQueues(queuesAi.data),
pyth: parsePrice(oracleAi.data),
};
}
function queueStateView(queues, queueId) {
if (queueId === 2) {
return {
ticketsTotal: queues.q2TicketsTotal,
ticketsPaid: queues.q2TicketsPaid,
sumTotalUsdCents: queues.q2SumTotalUsdCents,
sumPaidUsdCents: queues.q2SumPaidUsdCents,
};
}
if (queueId === 3) {
return {
ticketsTotal: queues.q3TicketsTotal,
ticketsPaid: queues.q3TicketsPaid,
sumTotalUsdCents: queues.q3SumTotalUsdCents,
sumPaidUsdCents: queues.q3SumPaidUsdCents,
};
}
return {
ticketsTotal: queues.q1TicketsTotal,
ticketsPaid: queues.q1TicketsPaid,
sumTotalUsdCents: queues.q1SumTotalUsdCents,
sumPaidUsdCents: queues.q1SumPaidUsdCents,
};
}
function queueSeedFor(queueId) {
if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2;
if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3;
return SHINE_PAYMENTS_SEEDS.q1;
}
function styleInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
}
function readTicketFromUrl() {
try {
const params = new URLSearchParams(window.location.search || '');
return String(params.get('ticket') || '').trim();
} catch {
return '';
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const content = document.createElement('div');
content.className = 'stack';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Страница готова. Введите номер билета.';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0;">Публичная очередь билета</h2>
<p class="meta-muted" style="margin:0; line-height:1.55;">
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате <code>2-15</code> или <code>3 8</code>.
Страница покажет состояние билета и сколько позиций осталось до выплаты.
</p>
`;
const inputLabel = document.createElement('label');
inputLabel.className = 'meta-muted';
inputLabel.setAttribute('for', 'public-queue-ticket-query');
inputLabel.textContent = 'Номер билета';
const queryInput = document.createElement('input');
queryInput.id = 'public-queue-ticket-query';
queryInput.type = 'text';
queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
queryInput.autocomplete = 'off';
queryInput.spellcheck = false;
styleInputField(queryInput);
queryInput.value = readTicketFromUrl();
const actions = document.createElement('div');
actions.className = 'row';
actions.innerHTML = `
<button class="primary-btn" type="button" id="public-queue-load" style="width:100%;">Показать билет</button>
<button class="ghost-btn" type="button" id="public-queue-reset" style="width:100%;">Сбросить</button>
`;
const result = document.createElement('div');
result.className = 'card stack';
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
let lastCoreState = null;
async function renderTicketInfo() {
const raw = String(queryInput.value || '').trim();
if (!raw) {
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
status.textContent = 'Введите номер билета.';
return;
}
try {
const parsed = parseSupportTicketInput(raw);
const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
lastCoreState = core;
const solana = await loadSolanaWeb3();
const ticketPdaSeed = queueSeedFor(parsed.queueId);
const ticketIndexBytes = u64ToBytes(parsed.index);
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
[utf8Bytes(ticketPdaSeed), ticketIndexBytes],
core.programId,
);
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
if (!ticketInfo) {
result.innerHTML = `
<p class="meta-muted" style="margin:0;">Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.</p>
`;
status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} не найден.`;
return;
}
const ticket = parsePaymentsTicket(ticketInfo.data);
const queueState = queueStateView(core.queues, parsed.queueId);
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
result.innerHTML = `
<div><b>Билет:</b> ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}</div>
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
`;
status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`;
} catch (error) {
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
status.textContent = `Не удалось посмотреть билет: ${error?.message || 'unknown'}`;
}
}
actions.querySelector('#public-queue-load')?.addEventListener('click', () => {
void renderTicketInfo();
});
actions.querySelector('#public-queue-reset')?.addEventListener('click', () => {
queryInput.value = '';
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
status.textContent = 'Поле билета очищено.';
});
queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void renderTicketInfo();
}
});
content.append(card, inputLabel, queryInput, actions, result, status);
screen.append(
renderHeader({
title: 'Очередь билета',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
content,
);
if (queryInput.value) {
void renderTicketInfo();
}
return screen;
}
+320 -82
View File
@@ -13,9 +13,39 @@ import {
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { sha256Text } from '../services/crypto-utils.js';
import { defaultServerHttp } from '../deploy-config.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
// ВРЕМЕННАЯ UI-ЗАГЛУШКА:
// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain,
// UI пускает логины 5..7 символов только по временному коду.
const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8;
const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5;
const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
function bytesToHex(bytes) {
return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join('');
}
async function isTemporaryPromoAccepted(value) {
const digest = await sha256Text(String(value || ''));
return bytesToHex(digest) === TEMP_PROMO_HASH_HEX;
}
function normalizeLoginForTemporaryUiGuard(login) {
const source = String(login || '').trim();
if (!source || source.length > 20) return null;
let normalized = '';
for (const ch of source) {
if (ch === '_') continue;
if (!/[0-9A-Za-z]/.test(ch)) return null;
normalized += ch.toLowerCase();
}
if (!normalized || normalized.length > 20) return null;
return normalized;
}
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
@@ -52,21 +82,46 @@ function createWordsLayout({ words, onInput }) {
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs };
return { section, inputs, preview };
}
function makePasswordToggleIcons() {
return {
eye: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
};
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack registration-screen';
screen.className = 'stack';
clearAuthMessages();
const form = document.createElement('div');
form.className = 'card stack registration-form';
form.className = 'card stack';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked);
let usePromoCode = Boolean(state.registrationDraft.usePromoCode);
let loginCheckTimer = 0;
let loginCheckRunId = 0;
const loginInput = document.createElement('input');
loginInput.className = 'input';
@@ -85,16 +140,31 @@ export function render({ navigate }) {
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.value = passwordMode === 'single' ? state.registrationDraft.password : '';
passwordInput.value = String(state.registrationDraft.password || '');
passwordInput.placeholder = 'Введите пароль';
const passwordIcons = makePasswordToggleIcons();
const passwordToggleButton = document.createElement('button');
passwordToggleButton.className = 'icon-btn key-toggle-btn registration-password-toggle';
passwordToggleButton.type = 'button';
passwordToggleButton.setAttribute('aria-label', 'Показать пароль');
passwordToggleButton.setAttribute('title', 'Показать пароль');
passwordToggleButton.innerHTML = passwordIcons.eyeOff;
const passwordInputRow = document.createElement('div');
passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton);
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
passwordWordsLinked = true;
syncDraftState();
updateWordsPreview();
},
@@ -112,23 +182,76 @@ export function render({ navigate }) {
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const promoToggle = document.createElement('label');
promoToggle.className = 'registration-toggle';
const promoCheckbox = document.createElement('input');
promoCheckbox.type = 'checkbox';
promoCheckbox.checked = usePromoCode;
const promoToggleLabel = document.createElement('span');
promoToggleLabel.textContent = 'У меня есть промокод';
promoToggle.append(promoCheckbox, promoToggleLabel);
const promoField = document.createElement('label');
promoField.className = 'stack';
const promoFieldLabel = document.createElement('span');
promoFieldLabel.className = 'field-label';
promoFieldLabel.textContent = 'Промокод';
const promoInput = document.createElement('input');
promoInput.className = 'input';
promoInput.type = 'text';
promoInput.autocomplete = 'off';
promoInput.autocapitalize = 'off';
promoInput.spellcheck = false;
promoInput.value = String(state.registrationDraft.promoCode || '');
promoInput.placeholder = 'Вставьте промокод';
const promoHint = document.createElement('p');
promoHint.className = 'meta-muted';
promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.';
promoField.append(promoFieldLabel, promoInput, promoHint);
const statusText = document.createElement('p');
statusText.className = 'status-line registration-login-status';
statusText.className = 'status-line';
statusText.style.display = 'none';
const serverNotice = document.createElement('div');
serverNotice.className = 'card stack';
serverNotice.innerHTML = `
<p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">При регистрации адресом вашего первого сервера будет: <strong>${state.entrySettings.shineServerHttp || defaultServerHttp}</strong>.</p>
`;
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
// Компактная ссылка на экран «Вопросы о регистрации» (registration-faq-view).
const faqButton = document.createElement('button');
faqButton.className = 'registration-faq-link';
faqButton.type = 'button';
faqButton.textContent = 'Вопросы о регистрации';
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить логин';
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
@@ -136,12 +259,10 @@ export function render({ navigate }) {
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'password-length-hint';
passwordLengthText.className = 'status-line';
let lastCheckedLogin = '';
let lastCheckedFree = false;
let lastCheckedClassName = '';
let loginCheckTimer = null;
let loginCheckRunId = 0;
let generationRunId = 0;
function getCurrentPassword() {
@@ -151,14 +272,50 @@ export function render({ navigate }) {
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function setStatusMessage(message, kind = '') {
statusText.textContent = message;
statusText.className = kind ? `status-line ${kind}` : 'status-line';
statusText.style.display = message ? '' : 'none';
}
function resetLoginCheckState() {
lastCheckedLogin = '';
lastCheckedFree = false;
lastCheckedClassName = '';
loginCheckRunId += 1;
if (loginCheckTimer) {
window.clearTimeout(loginCheckTimer);
loginCheckTimer = 0;
}
}
function scheduleAvailabilityCheck() {
resetLoginCheckState();
setStatusMessage('');
if (!loginInput.value.trim()) return;
loginCheckTimer = window.setTimeout(() => {
loginCheckTimer = 0;
runAvailabilityCheck({ automatic: true }).catch(() => {});
}, 1500);
}
function togglePasswordVisibility() {
const reveal = passwordInput.type === 'password';
passwordInput.type = reveal ? 'text' : 'password';
passwordToggleButton.innerHTML = reveal ? passwordIcons.eye : passwordIcons.eyeOff;
passwordToggleButton.setAttribute('aria-label', reveal ? 'Скрыть пароль' : 'Показать пароль');
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль');
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
passwordInputRow.style.display = wordsMode ? 'grid' : 'none';
updateWordsPreview();
}
@@ -166,114 +323,158 @@ export function render({ navigate }) {
state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.password = getCurrentPassword();
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = String(promoInput.value || '').trim();
}
async function runAvailabilityCheck() {
function updatePromoVisibility() {
promoField.style.display = usePromoCode ? 'grid' : 'none';
}
async function runAvailabilityCheck({ automatic = false } = {}) {
const runId = ++loginCheckRunId;
const login = loginInput.value.trim();
const promoCode = String(promoInput.value || '').trim();
if (!login) {
statusText.textContent = '';
statusText.style.display = 'none';
setStatusMessage(automatic ? '' : 'Введите логин');
formError.style.display = 'none';
return false;
}
const normalizedLogin = normalizeLoginForTemporaryUiGuard(login);
if (!normalizedLogin) {
setStatusMessage('Логин содержит недопустимые символы или имеет неверную длину ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) {
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO;
let promoAccepted = false;
if (usePromoCode) {
if (!promoCode) {
setStatusMessage('Введите промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
promoAccepted = await isTemporaryPromoAccepted(promoCode);
if (!promoAccepted) {
setStatusMessage('Неверный промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
} else if (wantsShortLogin) {
setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (lastCheckedClassName === 'free') {
statusText.textContent = 'Логин свободен ✅';
statusText.className = 'status-line is-available';
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
statusText.style.display = '';
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo');
}
const runId = ++loginCheckRunId;
statusText.className = 'status-line registration-login-status';
statusText.textContent = 'Проверяем логин...';
statusText.style.display = '';
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
if (automatic) setStatusMessage('Проверяем логин...');
try {
const check = await checkLoginExistsOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
if (runId !== loginCheckRunId) return false;
const isFree = !check.exists;
let className = '';
let precheckWarning = '';
if (isFree) {
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
precheckWarning = formatSolanaErrorDetails(precheckError);
if (promoAccepted) {
className = 'promo';
} else {
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
precheckWarning = formatSolanaErrorDetails(precheckError);
}
}
}
if (runId !== loginCheckRunId) return false;
lastCheckedLogin = login;
lastCheckedFree = isFree;
lastCheckedClassName = className;
if (!isFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (className === 'free') {
statusText.textContent = precheckWarning
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
: 'Логин свободен ✅';
statusText.className = 'status-line is-available';
setStatusMessage(
precheckWarning
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
: 'Логин свободен ✅',
'is-available',
);
} else if (className === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (className === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
statusText.style.display = '';
formError.style.display = 'none';
return isFree && className === 'free';
return isFree && (className === 'free' || className === 'promo');
} catch (error) {
if (runId !== loginCheckRunId) return false;
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
statusText.textContent = `${base}. Детали: ${details}`;
statusText.className = 'status-line is-unavailable';
statusText.style.display = '';
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
return false;
} finally {
if (runId === loginCheckRunId) {
checkButton.disabled = false;
checkButton.textContent = 'Проверить логин';
}
}
}
checkButton.addEventListener('click', () => runAvailabilityCheck());
passwordToggleButton.addEventListener('click', togglePasswordVisibility);
loginInput.addEventListener('input', () => {
syncDraftState();
lastCheckedLogin = '';
loginCheckRunId += 1;
if (loginCheckTimer) window.clearTimeout(loginCheckTimer);
statusText.textContent = '';
statusText.style.display = 'none';
if (!loginInput.value.trim()) return;
loginCheckTimer = window.setTimeout(() => {
runAvailabilityCheck();
}, 450);
scheduleAvailabilityCheck();
});
passwordInput.addEventListener('input', () => {
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
passwordWords = emptyPasswordWords();
passwordWordsLinked = false;
wordInputs.forEach((input) => {
input.value = '';
});
}
syncDraftState();
updateWordsPreview();
});
@@ -282,13 +483,16 @@ export function render({ navigate }) {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
if (!passwordWordsLinked) {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
}
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
passwordWordsLinked = true;
}
passwordMode = nextMode;
updatePasswordModeVisibility();
@@ -296,8 +500,33 @@ export function render({ navigate }) {
syncDraftState();
});
promoCheckbox.addEventListener('change', () => {
usePromoCode = promoCheckbox.checked;
resetLoginCheckState();
updatePromoVisibility();
syncDraftState();
if (usePromoCode) {
setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available');
} else {
setStatusMessage('');
scheduleAvailabilityCheck();
}
});
promoInput.addEventListener('input', () => {
resetLoginCheckState();
syncDraftState();
if (usePromoCode) scheduleAvailabilityCheck();
});
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const promoCode = String(promoInput.value || '').trim();
if (usePromoCode && !promoCode) {
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
formError.style.display = '';
return;
}
const isFree = await runAvailabilityCheck();
if (!isFree) return;
@@ -321,6 +550,9 @@ export function render({ navigate }) {
state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = promoCode;
if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null;
}
@@ -329,22 +561,28 @@ export function render({ navigate }) {
});
function renderInputStage() {
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
`;
const loginField = form.children[0];
passwordField = form.children[1];
serverNotice.style.display = '';
form.innerHTML = '';
const loginField = document.createElement('label');
loginField.className = 'stack';
loginField.innerHTML = '<span class="field-label">Логин</span>';
const passwordLabel = document.createElement('label');
passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, checkButton, passwordLabel);
passwordField = passwordLabel;
loginField.append(loginInput);
passwordField.append(passwordInput, passwordLengthText);
form.append(passwordModeToggle, wordsSection, statusText, formError);
passwordField.append(passwordInputRow);
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton);
actions.innerHTML = '';
actions.append(nextButton);
actions.append(backButton, nextButton);
updatePasswordModeVisibility();
updatePromoVisibility();
syncDraftState();
}
async function startGenerationStage() {
serverNotice.style.display = 'none';
const runId = ++generationRunId;
form.innerHTML = '';
@@ -450,7 +688,7 @@ export function render({ navigate }) {
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
form,
faqButton,
serverNotice,
actions,
);
+39 -10
View File
@@ -42,6 +42,22 @@ export function render({ navigate }) {
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const createKeyInfo = (toggle, titleText, descriptionText) => {
const wrap = document.createElement('div');
wrap.className = 'key-storage-option stack';
const row = document.createElement('label');
row.className = 'checkbox-row';
row.append(toggle, document.createTextNode(titleText));
const description = document.createElement('p');
description.className = 'meta-muted key-storage-option__description';
description.textContent = descriptionText;
wrap.append(row, description);
return wrap;
};
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
@@ -55,19 +71,29 @@ export function render({ navigate }) {
deviceToggle.checked = true;
deviceToggle.disabled = true;
const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row';
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
const rootRow = createKeyInfo(
rootToggle,
'Ключ root',
'Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.',
);
const blockchainRow = document.createElement('label');
blockchainRow.className = 'checkbox-row';
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
const blockchainRow = createKeyInfo(
blockchainToggle,
'Ключ blockchain',
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
);
const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row';
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
const deviceRow = createKeyInfo(
deviceToggle,
'Ключ device (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
);
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
const simpleNote = document.createElement('p');
simpleNote.className = 'key-storage-note key-storage-note--strong';
simpleNote.textContent = 'Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.';
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, simpleNote, status);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@@ -129,6 +155,9 @@ export function render({ navigate }) {
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
@@ -14,6 +14,7 @@ import {
getBalanceSol,
getTopupSiteUrl,
} from '../services/solana-wallet-service.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
formatSolanaErrorDetails,
isUserAlreadyExistsSolanaError,
@@ -81,6 +82,9 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
@@ -185,7 +189,7 @@ export function render({ navigate }) {
const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
const { PublicKey } = await loadSolanaWeb3();
const address = new PublicKey(bytes).toBase58();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
@@ -263,6 +267,7 @@ export function render({ navigate }) {
keyBundle,
solanaEndpoint: state.entrySettings.solanaServer,
accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin],
promoCode: '',
});
} catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError);
@@ -0,0 +1,147 @@
import { renderHeader } from '../components/header.js';
import {
isSessionInvalidError,
refreshSessions,
saveEntrySettings,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
export const pageMeta = { id: 'remote-addblock-session-view', title: 'AddBlock через homeserver' };
const SESSION_TYPE_HOMESERVER = 100;
function formatSessionTime(ms) {
return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function sessionLabel(session) {
const title = String(session?.clientInfoFromClient || '').trim();
if (title) return title;
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'AddBlock через homeserver',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const actions = document.createElement('div');
actions.className = 'card stack';
actions.innerHTML = `
<p class="meta-muted">Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.</p>
<button class="primary-btn" type="button" id="remote-addblock-refresh">Обновить список homeserver-сессий</button>
<button class="ghost-btn" type="button" id="remote-addblock-clear">Сбросить выбор</button>
`;
const listCard = document.createElement('div');
listCard.className = 'card stack';
const buildList = () => {
listCard.innerHTML = '';
const selectedId = String(state.entrySettings.remoteAddBlockSessionId || '').trim();
const sessions = (state.sessions || [])
.filter((item) => Number(item?.sessionType || 0) === SESSION_TYPE_HOMESERVER)
.sort((a, b) => Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0));
if (sessions.length === 0) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Активных homeserver-сессий не найдено.';
listCard.append(empty);
return;
}
sessions.forEach((session) => {
const sessionId = String(session?.sessionId || '').trim();
const item = document.createElement('button');
item.className = 'session-item';
item.type = 'button';
const isSelected = sessionId && sessionId === selectedId;
item.innerHTML = `
<div class="stack" style="gap:4px; text-align:left;">
<strong>${sessionLabel(session)}</strong>
<span class="meta-muted">${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
<span class="meta-muted">sessionId: ${sessionId || '-'}</span>
<span class="meta-muted">status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'}</span>
${isSelected ? '<span class="session-current-badge">Выбрана для remote AddBlock</span>' : ''}
</div>
`;
item.addEventListener('click', async () => {
try {
await saveEntrySettings({
...state.entrySettings,
remoteAddBlockSessionId: sessionId,
});
buildList();
setAuthInfo('Homeserver-сессия для remote AddBlock сохранена.');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
window.alert(error.message);
}
});
listCard.append(item);
});
};
actions.querySelector('#remote-addblock-refresh')?.addEventListener('click', async () => {
try {
await refreshSessions();
buildList();
setAuthInfo('Список homeserver-сессий обновлён.');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
window.alert(error.message);
}
});
actions.querySelector('#remote-addblock-clear')?.addEventListener('click', async () => {
try {
await saveEntrySettings({
...state.entrySettings,
remoteAddBlockSessionId: '',
});
buildList();
setAuthInfo('Выбор homeserver-сессии для remote AddBlock сброшен.');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
window.alert(error.message);
}
});
buildList();
screen.append(actions, listCard);
return screen;
}
+2
View File
@@ -45,6 +45,7 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-wallet">Кошелёк</button>
<button class="text-btn" type="button" id="settings-links">Показать связи</button>
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
@@ -57,6 +58,7 @@ export function render({ navigate }) {
navigate(makeProfileLinksRoute(state.session.login));
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
+2 -1
View File
@@ -5,13 +5,14 @@ import {
SOLANA_CLUSTER,
} from '../solana-programs.js';
import { state } from '../state.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
export const pageMeta = { id: 'solana-users-init-view', title: 'Solana Init (users)' };
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) {
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
solanaLibPromise = loadSolanaWeb3();
}
return solanaLibPromise;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
getTopupSiteUrl,
requestAirdropSol,
} from '../services/solana-wallet-service.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
@@ -20,7 +21,7 @@ async function clientWalletAddressFromBundle() {
const raw = atob(keyBundle.clientPair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
const { PublicKey } = await loadSolanaWeb3();
return new PublicKey(bytes).toBase58();
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -152,6 +152,10 @@ export function getRoute() {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
}
if (pageId === 'queue') {
return { pageId, params: {} };
}
return { pageId, params: {} };
}
@@ -178,6 +182,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'tools-settings-view' ||
pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
pageId === 'device-pairing-view' ||
File diff suppressed because it is too large Load Diff
+330 -59
View File
@@ -1,4 +1,5 @@
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js';
import { addAppLogEntry, addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
import { buildDmCallTechBlock } from './dm-tech-blocks.js';
const TYPES = {
INVITE: 100,
@@ -26,11 +27,98 @@ let toneFlip = false;
const DEFAULT_ICE_SERVERS = Object.freeze([
{ urls: 'stun:stun.l.google.com:19302' },
]);
const CALL_SUMMARY_MIN_TOTAL_MS = 5000;
const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000;
const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000;
const INCOMING_CONNECT_TIMEOUT_MS = 20_000;
const MAX_TIMELINE_ENTRIES = 64;
function nowMs() {
return Date.now();
}
function sanitizeTimelineText(value) {
return String(value || '')
.replace(/\s+/g, ' ')
.replace(/[,]/g, ';')
.trim();
}
function getSignalTypeName(type) {
const normalized = Number(type);
if (normalized === TYPES.INVITE) return 'invite';
if (normalized === TYPES.RINGING) return 'ringing';
if (normalized === TYPES.ACCEPT) return 'accept';
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
if (normalized === TYPES.TIMEOUT) return 'timeout';
if (normalized === TYPES.HANGUP) return 'hangup';
if (normalized === TYPES.OFFER) return 'offer';
if (normalized === TYPES.ANSWER) return 'answer';
if (normalized === TYPES.ICE) return 'ice';
return `type_${String(type || '')}`;
}
function ensureCallTimeline(call) {
if (!call) return;
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
if (!Number.isFinite(call.timelineSeq)) call.timelineSeq = 0;
if (!Number.isFinite(call.timelineBaseMs) || call.timelineBaseMs <= 0) {
call.timelineBaseMs = Number(call.startedAtMs || nowMs());
}
}
function recordCallTimeline(call, stage, details = '', level = 'info') {
if (!call) return;
ensureCallTimeline(call);
const ts = nowMs();
const deltaMs = Math.max(0, ts - Number(call.timelineBaseMs || ts));
const cleanStage = sanitizeTimelineText(stage) || 'unknown';
const cleanDetails = sanitizeTimelineText(details);
call.timelineSeq += 1;
call.timelineEvents.push({
seq: call.timelineSeq,
ts,
deltaMs,
stage: cleanStage,
details: cleanDetails,
});
if (call.timelineEvents.length > MAX_TIMELINE_ENTRIES) {
call.timelineEvents.splice(0, call.timelineEvents.length - MAX_TIMELINE_ENTRIES);
}
addAppLogEntry({
level,
source: 'call',
message: `[${call.callId || '?'}] +${deltaMs}ms ${cleanStage}`,
details: cleanDetails || buildCallFactsJson(call),
});
}
function buildTimelineSummary(call) {
const events = Array.isArray(call?.timelineEvents) ? call.timelineEvents : [];
if (!events.length) return '';
return events
.map((entry) => {
const head = `+${Number(entry?.deltaMs || 0)}:${sanitizeTimelineText(entry?.stage || '')}`;
const tail = sanitizeTimelineText(entry?.details || '');
return tail ? `${head}(${tail})` : head;
})
.join('|')
.slice(0, 1800);
}
function setRemoteSessionId(call, sessionId, reason = '') {
if (!call) return;
const next = String(sessionId || '').trim();
if (!next) return;
const prev = String(call.remoteSessionId || '').trim();
if (prev === next) {
recordCallTimeline(call, 'remote_session_confirmed', `session=${next}; reason=${reason || 'same'}`);
return;
}
call.remoteSessionId = next;
recordCallTimeline(call, 'remote_session_selected', `from=${prev || '-'}; to=${next}; reason=${reason || 'unknown'}`);
}
function resolveCallPreflightTimeoutMs() {
const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000);
return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000));
@@ -100,6 +188,102 @@ function formatDuration(ms) {
return `${sec}с`;
}
function isInviteUndelivered(call) {
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
const pushDelivered = Number(
call?.inviteDelivery?.deliveredWebPushSessions
|| call?.inviteDelivery?.deliveredFcmSessions
|| 0
);
return (wsDelivered + pushDelivered) <= 0;
}
function getInviteDeliveredCount(call) {
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
const pushDelivered = Number(
call?.inviteDelivery?.deliveredWebPushSessions
|| call?.inviteDelivery?.deliveredFcmSessions
|| 0
);
return wsDelivered + pushDelivered;
}
function buildOutgoingCallSummaryText(call, summaryCode) {
if (!call || call.direction !== 'out') return '';
if (summaryCode === 'completed') {
return buildDmCallTechBlock({
status: 'completed',
durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000),
});
}
if (summaryCode === 'busy') {
return buildDmCallTechBlock({ status: 'failed', reason: 'busy' });
}
if (summaryCode === 'declined') {
return buildDmCallTechBlock({ status: 'failed', reason: 'declined' });
}
if (summaryCode === 'no_answer') {
if (isInviteUndelivered(call)) {
return buildDmCallTechBlock({ status: 'failed', reason: 'offline' });
}
return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' });
}
if (summaryCode === 'error') {
return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' });
}
return '';
}
async function applyLocalOutgoingDmCopy(peerLogin, result) {
const localOutgoingBlobB64 = String(result?.localOutgoingBlobB64 || '').trim();
if (!peerLogin || !localOutgoingBlobB64) return false;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
addSignedMessageToChat({
chatId: peerLogin,
messageKey: String(result?.outgoingKey || parsed?.messageKey || ''),
baseKey: String(result?.baseKey || result?.localBaseKey || parsed?.baseKey || ''),
from: 'out',
text: String(decrypted?.text || ''),
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deleted: Boolean(parsed?.deleted),
});
return true;
} catch {
return false;
}
}
async function sendOutgoingCallSummaryDm(call, summaryCode) {
if (!call || call.direction !== 'out' || !call.peerLogin) return;
const totalMs = Math.max(0, nowMs() - Number(call.startedAtMs || nowMs()));
if (totalMs < CALL_SUMMARY_MIN_TOTAL_MS) return;
const text = buildOutgoingCallSummaryText(call, summaryCode);
if (!text) return;
const login = String(state?.session?.login || '').trim();
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
if (!login || !storagePwd) return;
try {
const result = await authService.sendDirectMessage({
login,
toLogin: call.peerLogin,
text,
storagePwd,
});
await applyLocalOutgoingDmCopy(call.peerLogin, result);
} catch (error) {
await emitDebug(call, 'warn', 'call_summary_dm_failed', toErrorText(error));
}
}
function cloneDefaultIceServers() {
return DEFAULT_ICE_SERVERS.map((row) => ({ ...row }));
}
@@ -404,6 +588,7 @@ function setStatus(call, statusText, phase = '') {
else if (call.phase === 'incoming') startTone('incoming');
else stopTone();
recordCallTimeline(call, 'status', `phase=${call.phase || ''}; text=${call.statusText || ''}`);
void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`);
notifyCallState();
}
@@ -443,6 +628,7 @@ function buildCallFactsJson(call, extra = {}) {
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
timeline: buildTimelineSummary(call),
...extra,
};
try {
@@ -472,6 +658,7 @@ function buildCallFactsLine(call, extra = {}) {
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
timeline: buildTimelineSummary(call),
...extra,
};
return Object.entries(facts)
@@ -546,6 +733,7 @@ async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', e
reason: String(reason || '').trim(),
reportedAtMs: nowMs(),
reportedAtIso: toIsoTs(nowMs()),
timelineEntriesCount: Array.isArray(call?.timelineEvents) ? call.timelineEvents.length : 0,
...diagnostics,
...extraFacts,
});
@@ -560,10 +748,49 @@ function cleanupTimers(call) {
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
if (call.timers?.total35s) clearTimeout(call.timers.total35s);
if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s);
if (call.timers?.incomingConnect20s) clearTimeout(call.timers.incomingConnect20s);
if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe);
call.timers.transportProbe = null;
}
function scheduleOutgoingAckTimeout(call) {
if (!call?.timers) return;
if (call.timers.ack10s) {
clearTimeout(call.timers.ack10s);
call.timers.ack10s = null;
}
const deliveredCount = getInviteDeliveredCount(call);
const timeoutMs = deliveredCount > 0
? OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS
: OUTGOING_NO_ACK_TIMEOUT_MS;
const debugReason = deliveredCount > 0 ? 'no_ack_after_delivery_25s' : 'no_ack_10s';
recordCallTimeline(call, 'ack_timeout_scheduled', `timeoutMs=${timeoutMs}; delivered=${deliveredCount}; reason=${debugReason}`);
call.timers.ack10s = setTimeout(() => {
if (!calls.has(call.callId)) return;
if (call.phase === 'searching' || call.phase === 'ringing') {
recordCallTimeline(call, 'ack_timeout_fired', `phase=${call.phase || ''}; reason=${debugReason}`, 'warn');
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason });
}
}, timeoutMs);
}
function scheduleIncomingConnectTimeout(call) {
if (!call?.timers) return;
if (call.timers.incomingConnect20s) {
clearTimeout(call.timers.incomingConnect20s);
}
recordCallTimeline(call, 'incoming_connect_timeout_scheduled', `timeoutMs=${INCOMING_CONNECT_TIMEOUT_MS}`);
call.timers.incomingConnect20s = setTimeout(() => {
if (!calls.has(call.callId)) return;
if (call.phase !== 'connecting') return;
recordCallTimeline(call, 'incoming_connect_timeout_fired', `phase=${call.phase || ''}`, 'warn');
void finalizeCall(call, {
localReasonCode: 'no_answer',
debugReason: 'incoming_connect_timeout_20s',
});
}, INCOMING_CONNECT_TIMEOUT_MS);
}
async function flushPendingIceCandidates(call) {
if (!call?.pc) return;
const pending = Array.isArray(call.pendingRemoteIceCandidates) ? call.pendingRemoteIceCandidates : [];
@@ -743,6 +970,7 @@ function startReconnectFlow(call, reason = 'disconnected') {
call.reconnectInProgress = true;
call.reconnectAttempts = 0;
recordCallTimeline(call, 'reconnect_start', `reason=${reason}`);
setStatus(call, 'Связь прервалась. Переподключаем…', 'reconnecting');
void emitDebug(call, 'warn', 'peer_connection_reconnect_start', `reason=${reason}`);
@@ -765,6 +993,7 @@ function startReconnectFlow(call, reason = 'disconnected') {
}
call.reconnectAttempts += 1;
recordCallTimeline(call, 'reconnect_attempt', `attempt=${call.reconnectAttempts}`);
try {
const offer = await call.pc.createOffer({ iceRestart: true });
await call.pc.setLocalDescription(offer);
@@ -791,49 +1020,6 @@ function startReconnectFlow(call, reason = 'disconnected') {
void runAttempt();
}
function pushCallSummary(call, summaryCode) {
if (!call?.peerLogin) return;
const outgoing = call.direction === 'out';
const from = outgoing ? 'out' : 'in';
if (summaryCode === 'busy') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (занято)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'no_answer') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (нет ответа)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin}`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'declined') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (отклонён)'
: `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'error') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (ошибка соединения)'
: `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
const label = outgoing ? 'Исходящий' : 'Входящий';
addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, {
from,
kind: 'call-tech',
});
}
}
async function finalizeCall(call, {
localReasonCode = 'error',
debugReason = '',
@@ -843,10 +1029,19 @@ async function finalizeCall(call, {
suppressSummary = false,
} = {}) {
if (!call) return;
recordCallTimeline(
call,
'finalize_begin',
`reason=${localReasonCode || ''}; debug=${debugReason || ''}; phase=${call.phase || ''}`,
String(localReasonCode || '') === 'completed' ? 'info' : 'warn',
);
const diagnosticsBeforeClose = getCallDiagnosticsContext(call);
cleanupTimers(call);
stopReconnectFlow(call);
stopTone();
if (String(debugReason || '') === 'remote_hangup' && call.direction === 'in') {
dismissCallUiLocally(call);
}
const shouldNotifyRemoteFailure =
!suppressRemoteSignal
@@ -902,7 +1097,7 @@ async function finalizeCall(call, {
}
if (!suppressSummary) {
pushCallSummary(call, localReasonCode);
await sendOutgoingCallSummaryDm(call, localReasonCode);
}
call.phase = 'ended';
@@ -910,6 +1105,7 @@ async function finalizeCall(call, {
if (String(localReasonCode || '') === 'busy') {
call.statusText = 'Пользователь занят';
}
recordCallTimeline(call, 'finalize_end', `reason=${localReasonCode || ''}; status=${call.statusText || ''}`);
notifyCallState();
const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0;
@@ -945,6 +1141,7 @@ async function emitDebug(call, level, message, details = '') {
async function sendSignal(call, type, data = '') {
if (!call.remoteSessionId) return;
const signalName = getSignalTypeName(type);
try {
await authService.callSignalToSession({
toLogin: call.peerLogin,
@@ -953,8 +1150,18 @@ async function sendSignal(call, type, data = '') {
type,
data,
});
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`);
} catch (error) {
if (String(error?.code || '').toUpperCase() === 'SESSION_NOT_FOUND') {
recordCallTimeline(
call,
`signal_out_${signalName}_session_not_found`,
`toSession=${call.remoteSessionId}; op=${String(error?.op || '')}; status=${String(error?.status || '')}`,
'warn',
);
}
recordCallTimeline(call, `signal_out_${signalName}_failed`, `toSession=${call.remoteSessionId}; error=${toErrorText(error)}`, 'warn');
await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error));
throw error;
}
@@ -993,6 +1200,14 @@ async function ensurePeerConnection(call) {
pc.onicecandidate = async (event) => {
if (!event.candidate || !call.remoteSessionId) return;
call.localIceSentCount = Number(call.localIceSentCount || 0) + 1;
if (call.localIceSentCount <= 3) {
recordCallTimeline(
call,
'ice_local_candidate',
`count=${call.localIceSentCount}; type=${event.candidate?.type || ''}; protocol=${event.candidate?.protocol || ''}`,
);
}
try {
await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate));
} catch {}
@@ -1000,8 +1215,18 @@ async function ensurePeerConnection(call) {
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
recordCallTimeline(
call,
'pc_connection_state',
`state=${state || ''}; ice=${pc.iceConnectionState || ''}; signal=${pc.signalingState || ''}`,
state === 'failed' ? 'warn' : 'info',
);
if (state === 'connected') {
stopReconnectFlow(call);
if (call.timers?.incomingConnect20s) {
clearTimeout(call.timers.incomingConnect20s);
call.timers.incomingConnect20s = null;
}
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
@@ -1070,6 +1295,7 @@ async function ensurePeerConnection(call) {
call.audioSenders = [];
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
stream.getTracks().forEach((track) => {
track.enabled = track.kind === 'audio' ? !call.muted : true;
const sender = pc.addTrack(track, stream);
@@ -1079,11 +1305,13 @@ async function ensurePeerConnection(call) {
});
} catch (e) {
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
throw e;
}
pc.ontrack = (evt) => {
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = evt.streams[0];
@@ -1103,6 +1331,7 @@ async function onAccept(call) {
await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`);
return;
}
recordCallTimeline(call, 'offer_start', `remoteSession=${call.remoteSessionId || ''}`);
call.initialOfferInProgress = true;
cleanupTimers(call);
setStatus(call, 'Соединяем…', 'connecting');
@@ -1192,10 +1421,14 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(callId, call);
recordCallTimeline(call, 'incoming_invite_created', `from=${fromLogin}; session=${fromSessionId}; source=${source}`);
} else if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
}
activeCallId = callId;
@@ -1351,10 +1584,14 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(cleanCallId, call);
activeCallId = cleanCallId;
recordCallTimeline(call, 'debug_initiator_created', `peer=${cleanPeerLogin}; session=${cleanPeerSessionId}`);
notifyCallState();
await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`);
try {
@@ -1404,35 +1641,47 @@ export async function startOutgoingCall(peerLogin) {
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(callId, call);
activeCallId = callId;
recordCallTimeline(call, 'outgoing_created', `peer=${cleanPeer}`);
setStatus(call, 'Ищем пользователя…', 'searching');
call.timers.ack10s = setTimeout(() => {
if (!calls.has(callId)) return;
if (call.phase === 'searching') {
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' });
}
}, 10000);
call.timers.total35s = setTimeout(() => {
if (!calls.has(callId)) return;
if (!call.connectedAtMs) {
recordCallTimeline(call, 'total_timeout_fired', 'total_timeout_35s', 'warn');
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' });
}
}, 35000);
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
recordCallTimeline(
call,
'invite_broadcast_ok',
`ws=${Number(call.inviteDelivery?.deliveredWsSessions || 0)}; push=${Number(call.inviteDelivery?.deliveredWebPushSessions || call.inviteDelivery?.deliveredFcmSessions || 0)}`,
);
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
setStatus(call, 'Вызываем…', 'ringing');
}
scheduleOutgoingAckTimeout(call);
} catch (error) {
recordCallTimeline(call, 'invite_broadcast_failed', toErrorText(error), 'warn');
const text = String(error?.message || '').toUpperCase();
const isNotAuth = text.includes('NOT_AUTHENTICATED');
if (isNotAuth) {
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
if (recovered) {
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
setStatus(call, 'Вызываем…', 'ringing');
}
scheduleOutgoingAckTimeout(call);
return;
} catch {}
}
@@ -1451,9 +1700,11 @@ export async function handleIncomingCallInvite(evt) {
export async function acceptIncomingCall() {
const call = getActiveCall();
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
call.phase = 'connecting';
setStatus(call, 'Соединяем…', 'connecting');
cleanupTimers(call);
scheduleIncomingConnectTimeout(call);
await sendSignal(call, TYPES.ACCEPT, 'accept');
}
@@ -1485,12 +1736,15 @@ export async function handleIncomingCallSignal(evt) {
const call = getCall(callId);
if (!call) return;
const signalName = getSignalTypeName(type);
recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`);
if (call.direction === 'out') {
if (type === TYPES.RINGING) {
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'ringing');
}
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1502,8 +1756,9 @@ export async function handleIncomingCallSignal(evt) {
} else if (type === TYPES.ACCEPT) {
if (fromSessionId) {
if (!call.remoteSessionId || !call.initialOfferSent) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock');
} else if (call.remoteSessionId !== fromSessionId) {
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
await emitDebug(
call,
'warn',
@@ -1528,6 +1783,7 @@ export async function handleIncomingCallSignal(evt) {
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
recordCallTimeline(call, 'terminal_signal_before_lock_allowed', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1535,6 +1791,7 @@ export async function handleIncomingCallSignal(evt) {
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
} else {
recordCallTimeline(call, 'signal_in_ignored_non_selected_session', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1545,11 +1802,11 @@ export async function handleIncomingCallSignal(evt) {
}
}
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, `first_${signalName}`);
}
}
} else if (!call.remoteSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, `incoming_${signalName}`);
}
if (type === TYPES.RINGING) {
@@ -1595,6 +1852,7 @@ export async function handleIncomingCallSignal(evt) {
if (type === TYPES.OFFER) {
try {
recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`);
const pc = await ensurePeerConnection(call);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
await flushPendingIceCandidates(call);
@@ -1632,6 +1890,7 @@ export async function handleIncomingCallSignal(evt) {
}
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
await flushPendingIceCandidates(call);
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
setStatus(call, 'Соединяем…', 'connecting');
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
} catch (error) {
@@ -1644,9 +1903,13 @@ export async function handleIncomingCallSignal(evt) {
if (type === TYPES.ICE) {
try {
const candidate = JSON.parse(data);
call.remoteIceReceivedCount = Number(call.remoteIceReceivedCount || 0) + 1;
if (!call.pc) {
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
call.pendingRemoteIceCandidates.push(candidate);
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_queued_no_pc', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
}
await emitDebug(call, 'info', 'ice_queued_before_pc', `queue=${call.pendingRemoteIceCandidates.length}`);
return;
}
@@ -1654,10 +1917,16 @@ export async function handleIncomingCallSignal(evt) {
if (!pc.remoteDescription) {
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
call.pendingRemoteIceCandidates.push(candidate);
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_queued_no_remote_description', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
}
await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`);
return;
}
await pc.addIceCandidate(new RTCIceCandidate(candidate));
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_applied', `count=${call.remoteIceReceivedCount}`);
}
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
} catch (error) {
await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error));
@@ -1669,6 +1938,7 @@ export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
dismissCallUiLocally(call);
recordCallTimeline(call, 'hangup_clicked', `connected=${Boolean(call?.connectedAtMs)}`);
await finalizeCall(call, {
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'hangup_by_user',
@@ -1695,6 +1965,7 @@ export async function handleStopCallPush(payload = {}) {
await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason);
return;
}
recordCallTimeline(call, 'stop_call_push', `fromSession=${fromSessionId || '-'}; reason=${reason}`);
await finalizeCall(call, {
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
debugReason: `stop_call_push:${reason}`,
+54
View File
@@ -1,6 +1,7 @@
const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() {
@@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
return new Uint8Array(signature);
}
export function extractEd25519SeedFromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
return bytes.slice(bytes.length - 32);
}
export function ed25519SeedToX25519Private(seed32) {
const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []);
if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта');
return new Uint8Array(edwardsToMontgomeryPriv(seed));
}
export function ed25519PublicToX25519Public(ed25519Pub32) {
const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []);
if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта');
return new Uint8Array(edwardsToMontgomeryPub(pub));
}
export function x25519RandomPrivateKey() {
return new Uint8Array(x25519.utils.randomPrivateKey());
}
export function x25519PublicFromPrivate(privateKey32) {
const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта');
return new Uint8Array(x25519.getPublicKey(key));
}
export function x25519SharedSecret(privateKey32, publicKey32) {
const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []);
if (priv.length !== 32 || pub.length !== 32) {
throw new Error('X25519 ключи должны быть по 32 байта');
}
return new Uint8Array(x25519.getSharedSecret(priv, pub));
}
export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) {
const subtle = getSubtleApi();
const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []),
info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []),
},
baseKey,
Number(outLen) * 8,
);
return new Uint8Array(bits);
}
+144
View File
@@ -0,0 +1,144 @@
function defaultParsed(rawText = '') {
const text = String(rawText || '');
return {
rawText: text,
prefixText: '',
visibleText: text,
displayText: text,
blocks: [],
replyRef: null,
callSummary: null,
};
}
function parseReplyId(value = '') {
const parts = String(value || '').split('|');
if (parts.length !== 4) return null;
const fromLogin = String(parts[0] || '').trim();
const toLogin = String(parts[1] || '').trim();
const timeMs = Number(parts[2] || 0);
const nonce = Number(parts[3] || 0);
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || timeMs <= 0 || !Number.isFinite(nonce) || nonce < 0) {
return null;
}
return {
fromLogin,
toLogin,
timeMs,
nonce,
baseKey: `${fromLogin}|${toLogin}|${timeMs}|${nonce}`,
};
}
function formatCallDuration(totalSeconds) {
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function buildCallDisplayText(callSummary) {
if (!callSummary) return '';
if (callSummary.status === 'completed') {
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
return 'Звонил, но недозвонился';
}
export function sanitizeUserDmTextForSend(rawText = '') {
const text = String(rawText || '');
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : text;
}
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
const cleanStatus = String(status || '').trim().toLowerCase();
if (cleanStatus === 'completed') {
return `<SHiNE:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
}
const cleanReason = String(reason || '').trim().toLowerCase();
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
}
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
const cleanBaseKey = String(baseKey || '').trim();
if (!cleanBaseKey) return '';
return `<SHiNE:reply;v=1;id=${cleanBaseKey}>`;
}
export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || '');
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
const blocks = [];
let cursor = 0;
let replyRef = null;
let callSummary = null;
while (text.startsWith('<SHiNE:', cursor)) {
const end = text.indexOf('>', cursor);
if (end < 0) {
if (!blocks.length) return defaultParsed(text);
break;
}
const inner = text.slice(cursor + 7, end);
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
if (!segments.length) {
if (!blocks.length) return defaultParsed(text);
break;
}
const kind = String(segments[0] || '').trim().toLowerCase();
const fields = {};
for (let i = 1; i < segments.length; i += 1) {
const part = segments[i];
const eq = part.indexOf('=');
if (eq <= 0) continue;
const key = part.slice(0, eq).trim().toLowerCase();
const value = part.slice(eq + 1).trim();
if (key) fields[key] = value;
}
const block = { kind, version: Number(fields.v || 0), fields };
blocks.push(block);
if (kind === 'reply' && !replyRef) {
replyRef = parseReplyId(fields.id || '');
} else if (kind === 'call' && !callSummary) {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
} else {
callSummary = {
status: 'failed',
reason: String(fields.reason || '').trim().toLowerCase(),
};
}
}
cursor = end + 1;
}
const visibleText = text.slice(cursor);
const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText;
return {
rawText: text,
prefixText: text.slice(0, cursor),
visibleText,
displayText,
blocks,
replyRef,
callSummary,
};
}
+88 -8
View File
@@ -1,4 +1,4 @@
import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './client-key-utils.js';
import {
SHINE_LOGIN_GUARD_PROGRAM_ID,
@@ -6,15 +6,18 @@ import {
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
} from '../solana-programs.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX = 'promo_seller=';
const LIMIT_STEP = 10_000n;
const BLOCKCHAIN_TYPE_MAIN_USER = 1;
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const PROMO_SIGN_PREFIX = 'shine_promo_v1:';
const BLOCK_TYPE_RECOVERY_KEY = 0;
const BLOCK_TYPE_ROOT_KEY = 1;
@@ -30,7 +33,7 @@ const SESSION_TYPE_HOMESERVER = 100;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
return solanaLibPromise;
}
@@ -152,11 +155,12 @@ function parseUsersEconomyConfig(dataBytes) {
};
}
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
function buildEd25519IxData(sig64, pubkey32, messageBytes) {
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const data = new Uint8Array(msgOff + 32);
const msg = messageBytes instanceof Uint8Array ? messageBytes : new Uint8Array(messageBytes || []);
const data = new Uint8Array(msgOff + msg.length);
const view = new DataView(data.buffer);
data[0] = 1;
data[1] = 0;
@@ -165,11 +169,11 @@ function buildEd25519IxData(sig64, pubkey32, msgHash32) {
view.setUint16(6, pkOff, true);
view.setUint16(8, 0xffff, true);
view.setUint16(10, msgOff, true);
view.setUint16(12, 32, true);
view.setUint16(12, msg.length, true);
view.setUint16(14, 0xffff, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
data.set(msg, msgOff);
return data;
}
@@ -213,6 +217,10 @@ function serializeCreateUserPdaArgs(args) {
}
buf.push(Number(args.trustedCount || 0) & 0xff);
for (const x of args.rootSignature64) buf.push(x);
const promoSellerLogin = normalizeLogin(String(args.promoSellerLogin || '').trim());
if (promoSellerLogin) {
pushStrU8(buf, promoSellerLogin);
}
return new Uint8Array(buf);
}
@@ -362,6 +370,37 @@ export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber
return new Uint8Array(buf);
}
export function buildPromoMessageBytes(login) {
return new TextEncoder().encode(`${PROMO_SIGN_PREFIX}${normalizeLogin(login)}`);
}
export function buildPromoCodeString(sellerLogin, signatureBytes) {
const cleanSellerLogin = normalizeLogin(sellerLogin);
if (!cleanSellerLogin) {
throw new Error('Не указан логин продавца');
}
const sig = signatureBytes instanceof Uint8Array ? signatureBytes : new Uint8Array(signatureBytes || []);
if (sig.length !== 64) {
throw new Error('Подпись промокода должна быть ровно 64 байта');
}
return `1${cleanSellerLogin}-${bytesToBase58(sig)}`;
}
export async function findPromoSellerPda({ sellerLogin }) {
const cleanSellerLogin = normalizeLogin(sellerLogin);
if (!cleanSellerLogin) throw new Error('Не указан логин продавца');
const solana = await loadSolanaLib();
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [promoSellerPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanSellerLogin)],
usersProgram,
);
return {
sellerLogin: cleanSellerLogin,
promoSellerPda: promoSellerPda.toBase58(),
};
}
export function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
@@ -769,6 +808,7 @@ async function createShineUserPdaOnSolana({
serverAddress = '',
syncServers = [],
accessServers = [],
promoCode = '',
}) {
const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint });
const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda);
@@ -777,6 +817,7 @@ async function createShineUserPdaOnSolana({
}
const cleanLogin = ctx.cleanLogin;
const cleanPromoCode = String(promoCode || '').trim();
const blockchainName = `${cleanLogin}-001`;
const zeroHash32 = new Uint8Array(32);
const createdAtMs = BigInt(Date.now());
@@ -820,6 +861,39 @@ async function createShineUserPdaOnSolana({
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash);
let promoSellerPda = null;
let promoEd25519Ix = null;
let promoSellerLogin = '';
if (cleanPromoCode) {
const dashPos = cleanPromoCode.indexOf('-');
if (!cleanPromoCode.startsWith('1') || dashPos <= 1 || dashPos === cleanPromoCode.length - 1) {
throw new Error('Некорректный формат промокода');
}
const sellerLogin = normalizeLogin(cleanPromoCode.slice(1, dashPos));
const signatureBase58 = cleanPromoCode.slice(dashPos + 1);
promoSellerLogin = sellerLogin;
const [promoSellerAddress] = ctx.solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(sellerLogin)],
ctx.usersProgram,
);
promoSellerPda = promoSellerAddress;
const promoMessage = buildPromoMessageBytes(cleanLogin);
const signatureBytes = base58ToBytes(signatureBase58);
if (signatureBytes.length !== 64) {
throw new Error('Подпись промокода должна быть 64 байта в Base58');
}
const sellerAccount = await ctx.connection.getAccountInfo(promoSellerAddress, 'confirmed');
if (!sellerAccount?.data || sellerAccount.data.length < 42) {
throw new Error('Promo seller PDA не найдена или повреждена');
}
const signerPubkey = new Uint8Array(sellerAccount.data.slice(10, 42));
promoEd25519Ix = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(signatureBytes, signerPubkey, promoMessage),
});
}
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
recoveryKey32: ctx.recoveryKey32,
@@ -844,6 +918,7 @@ async function createShineUserPdaOnSolana({
sessions: [],
trustedCount: 0,
rootSignature64: rootSig64,
promoSellerLogin,
});
const ed25519RootIx = new ctx.solana.TransactionInstruction({
@@ -866,15 +941,19 @@ async function createShineUserPdaOnSolana({
{ pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false },
{ pubkey: ctx.loginGuardProgram, isSigner: false, isWritable: false },
...(promoSellerPda ? [{ pubkey: promoSellerPda, isSigner: false, isWritable: true }] : []),
],
data: ixData,
});
let signature;
try {
const tx = new ctx.solana.Transaction();
if (promoEd25519Ix) tx.add(promoEd25519Ix);
tx.add(ed25519RootIx, ed25519BchIx, createIx);
signature = await ctx.solana.sendAndConfirmTransaction(
ctx.connection,
new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx),
tx,
[ctx.clientKeypair],
{ commitment: 'confirmed' },
);
@@ -890,13 +969,14 @@ async function createShineUserPdaOnSolana({
};
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [] }) {
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) {
return createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer: false,
accessServers: Array.isArray(accessServers) ? accessServers : [],
promoCode,
});
}
@@ -3,14 +3,15 @@ import {
SHINE_USERS_PROGRAM_ID,
SHINE_LOGIN_GUARD_PROGRAM_ID,
} from '../solana-programs.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
const CLASSIFY_LOGIN_INSTRUCTION_TAG = 1;
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
const PRECHECK_SIM_PAYER = 'SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
return solanaLibPromise;
}
@@ -138,6 +139,6 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
return { exists: !!ai, userPda: userPda.toBase58() };
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers }) {
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers });
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers, promoCode = '' }) {
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers, promoCode });
}
@@ -1,6 +1,7 @@
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
const TOPUP_SITE_URL = '/devnet-topup-view';
@@ -24,7 +25,7 @@ function normalizeEndpoint(url) {
async function loadSolanaLib() {
if (!solanaLibPromise) {
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
solanaLibPromise = loadSolanaWeb3();
}
return solanaLibPromise;
}
+8 -5
View File
@@ -1,9 +1,12 @@
export const SOLANA_CLUSTER = 'devnet';
export const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
import { defaultSolanaCluster, defaultSolanaEndpoint } from './deploy-config.js';
export const SOLANA_CLUSTER = defaultSolanaCluster;
// Для браузерного UI endpoint выбирается на уровне deploy-config.js конкретного инстанса.
export const SOLANA_ENDPOINT_DEFAULT = defaultSolanaEndpoint;
// Единый файл актуальных Solana Program ID для UI-части SHiNE.
// При смене адресов обновлять этот файл и все его потребители внутри shine-UI.
export const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ';
export const SHINE_USERS_PROGRAM_ID = 'SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6';
export const SHINE_USERS_ECONOMY_CONFIG_SEED = 'shine_users_economy_config';
export const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
export const SHINE_PAYMENTS_PROGRAM_ID = 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW';
export const SHINE_LOGIN_GUARD_PROGRAM_ID = 'SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh';
export const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
+104 -5
View File
@@ -97,6 +97,21 @@ const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
function normalizeStoredSolanaServer(value) {
const raw = String(value || '').trim();
if (!raw) return DEFAULT_SOLANA_SERVER;
const normalized = raw.replace(/\/+$/u, '').toLowerCase();
if (
normalized === 'https://api.devnet.solana.com'
|| normalized === 'http://api.devnet.solana.com'
|| normalized === 'https://api.mainnet-beta.solana.com'
|| normalized === 'http://api.mainnet-beta.solana.com'
) {
return DEFAULT_SOLANA_SERVER;
}
return raw;
}
export function normalizeDmChatId(value) {
return String(value || '').trim().toLowerCase();
}
@@ -187,12 +202,13 @@ function persistEntrySettings(settings) {
try {
const payload = {
language: String(settings?.language || 'ru'),
solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER),
solanaServer: normalizeStoredSolanaServer(settings?.solanaServer),
shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER),
shineServerLogin: String(settings?.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE),
shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
remoteAddBlockSessionId: String(settings?.remoteAddBlockSessionId || ''),
statuses: {
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || 'idle'),
@@ -242,6 +258,8 @@ function createInitialState({ withStoredSession = true } = {}) {
appLog: [],
incomingDedup: {},
knownMessageKeys: {},
pendingOutgoingReadByBaseKey: {},
pendingIncomingReadByBaseKey: {},
outgoingTempSeq: 1,
notificationsTab: 'replies',
pageLabelCollapsed: false,
@@ -255,12 +273,13 @@ function createInitialState({ withStoredSession = true } = {}) {
startHint: '',
entrySettings: {
language: String(storedEntrySettings?.language || 'ru'),
solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER),
solanaServer: normalizeStoredSolanaServer(storedEntrySettings?.solanaServer),
shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer),
shineServerLogin: String(storedEntrySettings?.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE),
shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
remoteAddBlockSessionId: String(storedEntrySettings?.remoteAddBlockSessionId || ''),
statuses: {
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'),
@@ -274,6 +293,9 @@ function createInitialState({ withStoredSession = true } = {}) {
password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
passwordWordsLinked: false,
usePromoCode: false,
promoCode: '',
sessionId: '',
storagePwd: '',
pendingKeyBundle: null,
@@ -297,7 +319,7 @@ function createInitialState({ withStoredSession = true } = {}) {
rootKey: 'Ключ root хранится в зашифрованном виде',
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
clientKey: 'Ключ device хранится в зашифрованном виде',
saveRoot: false,
saveRoot: true,
saveBlockchain: true,
saveDevice: true,
},
@@ -323,6 +345,11 @@ function createInitialState({ withStoredSession = true } = {}) {
export const state = createInitialState();
export const authService = new AuthService(state.entrySettings.shineServer);
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
authService.setActiveSessionContext({
login: state.session.login,
sessionId: state.session.sessionId,
});
let onSessionReset = null;
let onSessionAuthorized = null;
@@ -541,32 +568,48 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
export function markOutgoingReadByBaseKey(baseKey) {
if (!baseKey) return;
const keys = Object.keys(state.chats || {});
let matched = false;
keys.forEach((chatId) => {
const list = getChatMessages(chatId);
list.forEach((row) => {
if (row?.from !== 'out') return;
if (row.baseKey === baseKey) {
matched = true;
row.secondTick = true;
persistMessageRecord(chatId, row);
}
});
});
if (matched) {
delete state.pendingOutgoingReadByBaseKey[baseKey];
} else {
state.pendingOutgoingReadByBaseKey[baseKey] = true;
}
return matched;
}
export function markIncomingReadByBaseKey(baseKey) {
if (!baseKey) return;
const keys = Object.keys(state.chats || {});
let matched = false;
keys.forEach((chatId) => {
const list = getChatMessages(chatId);
list.forEach((row) => {
if (row?.from !== 'in') return;
if (row.baseKey === baseKey) {
matched = true;
row.unread = false;
row.readReceiptSent = true;
persistMessageRecord(chatId, row);
}
});
});
if (matched) {
delete state.pendingIncomingReadByBaseKey[baseKey];
} else {
state.pendingIncomingReadByBaseKey[baseKey] = true;
}
return matched;
}
export function markReadReceiptSentByBaseKey(baseKey) {
@@ -634,6 +677,15 @@ export function addSignedMessageToChat({
row.firstTick = row.from === 'out';
row.secondTick = Boolean(existing?.secondTick);
row.readReceiptSent = Boolean(existing?.readReceiptSent);
if (row.baseKey && row.from === 'out' && state.pendingOutgoingReadByBaseKey[row.baseKey]) {
row.secondTick = true;
delete state.pendingOutgoingReadByBaseKey[row.baseKey];
}
if (row.baseKey && row.from === 'in' && state.pendingIncomingReadByBaseKey[row.baseKey]) {
row.unread = false;
row.readReceiptSent = true;
delete state.pendingIncomingReadByBaseKey[row.baseKey];
}
if (existingIndex < 0) {
list.push(row);
@@ -643,15 +695,57 @@ export function addSignedMessageToChat({
return true;
}
export function deleteSignedMessageByBaseKey(chatId, baseKey) {
const normalizedChatId = normalizeDmChatId(chatId);
const normalizedBaseKey = String(baseKey || '').trim();
if (!normalizedChatId || !normalizedBaseKey) return false;
const list = getChatMessages(normalizedChatId);
let changed = false;
for (let i = list.length - 1; i >= 0; i -= 1) {
const row = list[i];
if (String(row?.baseKey || '').trim() !== normalizedBaseKey) continue;
changed = true;
removeStoredMessageRecord(String(row?.messageKey || '').trim());
list.splice(i, 1);
}
if (changed) {
sortChatMessagesInPlace(normalizedChatId);
}
return changed;
}
export function deleteConversationMessagesBefore(chatId, boundaryTimeMs) {
const normalizedChatId = normalizeDmChatId(chatId);
const boundary = Number(boundaryTimeMs || 0);
if (!normalizedChatId || !Number.isFinite(boundary) || boundary <= 0) return 0;
const list = getChatMessages(normalizedChatId);
let removed = 0;
for (let i = list.length - 1; i >= 0; i -= 1) {
const row = list[i];
const rowTime = resolveChatMessageTimeMs(row);
if (!Number.isFinite(rowTime) || rowTime >= boundary) continue;
removed += 1;
removeStoredMessageRecord(String(row?.messageKey || '').trim());
list.splice(i, 1);
}
if (removed > 0) {
sortChatMessagesInPlace(normalizedChatId);
}
return removed;
}
export function markChatRead(chatId) {
const normalizedChatId = normalizeDmChatId(chatId);
const list = getChatMessages(normalizedChatId);
let changed = 0;
list.forEach((row) => {
if (row?.from === 'in') {
if (row?.from === 'in' && row?.unread) {
row.unread = false;
persistMessageRecord(normalizedChatId, row);
changed += 1;
}
});
return changed;
}
export function setContacts(list) {
@@ -716,7 +810,7 @@ export function checkServerAvailability(address) {
}
export async function saveEntrySettings(nextSettings) {
const nextSolanaServer = String(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER);
const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER);
const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase()
|| DEFAULT_SHINE_SERVER_LOGIN_VALUE;
let forcedShineServer = LOCAL_WS_OVERRIDE_URL || '';
@@ -745,6 +839,7 @@ export async function saveEntrySettings(nextSettings) {
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
};
persistEntrySettings(state.entrySettings);
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
await authService.reconnect(state.entrySettings.shineServer);
state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`;
}
@@ -788,6 +883,7 @@ export function authorizeSession({
login,
sessionId,
});
authService.setActiveSessionContext({ login, sessionId });
state.startHint = '';
if (onSessionAuthorized) {
onSessionAuthorized();
@@ -833,6 +929,8 @@ function resetStateForSignedOut() {
state.appLog = next.appLog;
state.incomingDedup = next.incomingDedup;
state.knownMessageKeys = next.knownMessageKeys;
state.pendingOutgoingReadByBaseKey = next.pendingOutgoingReadByBaseKey;
state.pendingIncomingReadByBaseKey = next.pendingIncomingReadByBaseKey;
state.outgoingTempSeq = next.outgoingTempSeq;
state.notificationsTab = next.notificationsTab;
state.pageLabelCollapsed = next.pageLabelCollapsed;
@@ -877,6 +975,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
resetStateForSignedOut();
await clearStoredMessages().catch(() => {});
authService.close();
authService.clearActiveSessionContext();
if (infoMessage) {
state.startHint = infoMessage;
}
+45
View File
@@ -0,0 +1,45 @@
const SOLANA_WEB3_SCRIPT_SRC = './solana-web3.iife.min.js';
let solanaWeb3Promise = null;
function getExistingScript() {
return document.querySelector('script[data-shine-solana-web3="1"]');
}
function resolveScriptUrl() {
return new URL(SOLANA_WEB3_SCRIPT_SRC, import.meta.url).toString();
}
export function loadSolanaWeb3() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(new Error('Solana Web3 loader доступен только в браузере'));
}
if (window.solanaWeb3) {
return Promise.resolve(window.solanaWeb3);
}
if (!solanaWeb3Promise) {
solanaWeb3Promise = new Promise((resolve, reject) => {
const existing = getExistingScript();
if (existing) {
existing.addEventListener('load', () => resolve(window.solanaWeb3), { once: true });
existing.addEventListener('error', () => reject(new Error('Не удалось загрузить локальный solana-web3.js')), { once: true });
return;
}
const script = document.createElement('script');
script.src = resolveScriptUrl();
script.async = true;
script.dataset.shineSolanaWeb3 = '1';
script.onload = () => {
if (!window.solanaWeb3) {
reject(new Error('Локальный solana-web3.js загружен, но объект solanaWeb3 недоступен'));
return;
}
resolve(window.solanaWeb3);
};
script.onerror = () => reject(new Error('Не удалось загрузить локальный solana-web3.js'));
document.head.appendChild(script);
});
}
return solanaWeb3Promise;
}
File diff suppressed because one or more lines are too long
+555
View File
@@ -0,0 +1,555 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE Promo Code Generator</title>
<style>
:root {
color-scheme: dark;
--bg: #091019;
--card: rgba(18, 29, 42, 0.95);
--card-2: rgba(11, 18, 28, 0.98);
--line: rgba(116, 146, 189, 0.22);
--text: #edf4ff;
--muted: #95a8c4;
--accent: #61d3a7;
--accent-2: #58a8ff;
--danger: #ff7f97;
--shadow: 0 26px 90px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Inter", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(88, 168, 255, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(97, 211, 167, 0.16), transparent 36%),
linear-gradient(180deg, #060b12 0%, var(--bg) 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.panel {
width: min(840px, 100%);
background: linear-gradient(180deg, var(--card) 0%, var(--card-2) 100%);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 28px;
backdrop-filter: blur(14px);
}
h1 {
margin: 0 0 10px;
font-size: clamp(28px, 4vw, 40px);
line-height: 1.06;
letter-spacing: -0.03em;
}
.lead, .hint {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.layout {
display: grid;
gap: 16px;
grid-template-columns: 1fr 1fr;
margin-top: 24px;
}
.block {
background: rgba(8, 14, 21, 0.72);
border: 1px solid rgba(116, 146, 189, 0.16);
border-radius: 18px;
padding: 16px;
}
.full { grid-column: 1 / -1; }
.field-label, .block strong {
display: block;
margin-bottom: 8px;
color: #dce9ff;
font-size: 14px;
}
input, textarea {
width: 100%;
border: 1px solid rgba(116, 146, 189, 0.18);
background: rgba(5, 11, 17, 0.92);
color: var(--text);
border-radius: 12px;
padding: 13px 14px;
font: inherit;
}
textarea {
min-height: 124px;
resize: vertical;
}
.mode-switch {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.mode-switch label {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(116, 146, 189, 0.18);
border-radius: 999px;
padding: 10px 14px;
cursor: pointer;
color: var(--text);
background: rgba(8, 14, 21, 0.82);
}
.mode-switch input { width: auto; }
.actions, .seed-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
button {
border: 0;
border-radius: 999px;
padding: 13px 18px;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
}
button:hover { transform: translateY(-1px); }
button:disabled {
cursor: default;
opacity: 0.55;
transform: none;
}
.primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #041219;
}
.secondary {
background: rgba(76, 108, 149, 0.18);
color: var(--text);
border: 1px solid rgba(116, 146, 189, 0.18);
}
.status {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(116, 146, 189, 0.18);
background: rgba(6, 11, 17, 0.74);
color: var(--muted);
white-space: pre-wrap;
}
.status.error {
color: #ffd7df;
border-color: rgba(255, 127, 151, 0.36);
background: rgba(54, 12, 22, 0.52);
}
.status.success {
color: #defff3;
border-color: rgba(97, 211, 167, 0.36);
background: rgba(8, 38, 29, 0.52);
}
.muted-text {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 8px 0 0;
word-break: break-word;
}
.hidden { display: none !important; }
code {
padding: 2px 5px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
}
@media (max-width: 760px) {
.panel {
padding: 20px;
border-radius: 20px;
}
.layout {
grid-template-columns: 1fr;
}
.full { grid-column: auto; }
}
</style>
</head>
<body>
<main class="panel">
<h1>Генератор promo-кодов</h1>
<p class="lead">
Полностью автономный HTML-файл. Можно открыть локально без сервера и без внешних зависимостей.
Подписывается строка <code>shine_promo_v1:&lt;login&gt;</code>, а на выходе получается promo-код
формата <code>1seller-signatureBase58</code>.
</p>
<section class="layout">
<div class="block full">
<strong>Режим подписи</strong>
<div class="mode-switch">
<label><input type="radio" name="signMode" value="extension" checked> Wallet extension</label>
<label><input type="radio" name="signMode" value="private"> Приватный ключ Base58</label>
</div>
</div>
<div class="block">
<label class="field-label" for="sellerLogin">Логин продавца</label>
<input id="sellerLogin" type="text" autocomplete="off" placeholder="например promo_master">
</div>
<div class="block">
<label class="field-label" for="targetLogin">Новый логин</label>
<input id="targetLogin" type="text" autocomplete="off" placeholder="например SuperName">
</div>
<div class="block full" id="extensionBlock">
<strong>Подключённый кошелёк</strong>
<div class="muted-text" id="walletAddress">не подключен</div>
<div class="actions" style="margin-top: 14px;">
<button class="secondary" type="button" id="connectBtn">Подключить кошелёк</button>
</div>
</div>
<div class="block full hidden" id="privateKeyBlock">
<label class="field-label" for="privateSeed">Приватный ключ продавца</label>
<input id="privateSeed" type="text" autocomplete="off" placeholder="Base58 seed 32 bytes">
<div class="seed-actions" style="margin-top: 14px;">
<button class="secondary" type="button" id="generateSeedBtn">Сгенерировать новый Base58 seed</button>
<button class="secondary" type="button" id="copySeedBtn">Скопировать seed</button>
</div>
<p class="muted-text" id="privateSeedHint">
Формат: Base58 seed длиной 32 байта. Страница импортирует его в Ed25519 и локально подписывает сообщение через WebCrypto.
</p>
</div>
<div class="block full">
<strong>Подписываемое сообщение</strong>
<div class="muted-text" id="messagePreview"></div>
</div>
<div class="block full">
<div class="actions">
<button class="primary" type="button" id="generateBtn">Сгенерировать promo-код</button>
<button class="secondary" type="button" id="copyCodeBtn" disabled>Скопировать promo-код</button>
</div>
</div>
<div class="block full">
<label class="field-label" for="promoCode">Готовый promo-код</label>
<textarea id="promoCode" readonly placeholder="Здесь появится готовая строка promo-кода"></textarea>
</div>
</section>
<p class="status" id="status">Готово. Можно работать полностью локально.</p>
</main>
<script>
(() => {
const LOGIN_RE = /^[0-9A-Za-z_]{1,20}$/;
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
const PROMO_PREFIX = 'shine_promo_v1:';
const $ = (id) => document.getElementById(id);
const sellerLoginInput = $('sellerLogin');
const targetLoginInput = $('targetLogin');
const walletAddressNode = $('walletAddress');
const privateSeedInput = $('privateSeed');
const privateSeedHint = $('privateSeedHint');
const messagePreviewNode = $('messagePreview');
const promoCodeNode = $('promoCode');
const statusNode = $('status');
const generateBtn = $('generateBtn');
const copyCodeBtn = $('copyCodeBtn');
const connectBtn = $('connectBtn');
const generateSeedBtn = $('generateSeedBtn');
const copySeedBtn = $('copySeedBtn');
const extensionBlock = $('extensionBlock');
const privateKeyBlock = $('privateKeyBlock');
let currentMode = 'extension';
let provider = null;
let walletAddress = '';
function setStatus(text, kind = '') {
statusNode.className = `status ${kind}`.trim();
statusNode.textContent = String(text || '');
}
function normalizeLogin(value) {
return String(value || '').trim().toLowerCase();
}
function validateLogin(label, raw) {
const clean = normalizeLogin(raw);
if (!LOGIN_RE.test(clean)) {
throw new Error(`${label}: разрешены только 0..9, a..z, A..Z, _ и длина 1..20`);
}
return clean;
}
function bytesToBase58(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length === 0) return '';
const digits = [0];
for (const byte of input) {
let carry = byte;
for (let i = 0; i < digits.length; i += 1) {
const value = (digits[i] * 256) + carry;
digits[i] = value % 58;
carry = Math.floor(value / 58);
}
while (carry > 0) {
digits.push(carry % 58);
carry = Math.floor(carry / 58);
}
}
let out = '';
for (const byte of input) {
if (byte === 0) out += '1';
else break;
}
for (let i = digits.length - 1; i >= 0; i -= 1) {
out += BASE58_ALPHABET[digits[i]];
}
return out;
}
function base58ToBytes(value) {
const text = String(value || '').trim();
if (!text) return new Uint8Array(0);
const bytes = [0];
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
const digit = BASE58_ALPHABET.indexOf(ch);
if (digit < 0) throw new Error('Недопустимый символ Base58');
let carry = digit;
for (let j = 0; j < bytes.length; j += 1) {
const x = (bytes[j] * 58) + carry;
bytes[j] = x & 0xff;
carry = x >> 8;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
bytes.push(0);
}
bytes.reverse();
return Uint8Array.from(bytes);
}
function pkcs8FromSeed32(seed32) {
const out = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed32.length);
out.set(ED25519_PKCS8_PREFIX, 0);
out.set(seed32, ED25519_PKCS8_PREFIX.length);
return out;
}
async function signWithSeed32(seed32, messageBytes) {
if (!globalThis.crypto?.subtle) {
throw new Error('В браузере нет WebCrypto subtle. Для локальной подписи нужен современный браузер.');
}
const privateKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8FromSeed32(seed32),
{ name: 'Ed25519' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('Ed25519', privateKey, messageBytes);
return new Uint8Array(signature);
}
function buildPromoMessageBytes(login) {
return new TextEncoder().encode(`${PROMO_PREFIX}${login}`);
}
function buildPromoCodeString(sellerLogin, signatureBytes) {
if (!(signatureBytes instanceof Uint8Array) || signatureBytes.length !== 64) {
throw new Error('Подпись промокода должна быть ровно 64 байта');
}
return `1${sellerLogin}-${bytesToBase58(signatureBytes)}`;
}
function detectWalletProvider() {
const injected = [];
if (globalThis.shineSolana) injected.push(globalThis.shineSolana);
if (globalThis.shineWallet?.solana) injected.push(globalThis.shineWallet.solana);
if (globalThis.solana?.providers && Array.isArray(globalThis.solana.providers)) {
injected.push(...globalThis.solana.providers);
}
if (globalThis.solana) injected.push(globalThis.solana);
if (globalThis.phantom?.solana) injected.push(globalThis.phantom.solana);
const seen = new Set();
return injected.find((candidate) => {
if (!candidate || seen.has(candidate)) return false;
seen.add(candidate);
return typeof candidate.connect === 'function' && typeof candidate.signMessage === 'function';
}) || null;
}
function updateMessagePreview() {
const login = normalizeLogin(targetLoginInput.value);
if (!login) {
messagePreviewNode.textContent = '—';
return;
}
messagePreviewNode.textContent = new TextDecoder().decode(buildPromoMessageBytes(login));
}
function updateModeUi() {
extensionBlock.classList.toggle('hidden', currentMode !== 'extension');
privateKeyBlock.classList.toggle('hidden', currentMode !== 'private');
}
async function connectWallet() {
provider = detectWalletProvider();
if (!provider) {
throw new Error('Не найдено wallet extension с connect/signMessage');
}
const result = await provider.connect();
const publicKey = result?.publicKey || provider.publicKey;
if (!publicKey) {
throw new Error('Кошелёк не вернул public key');
}
walletAddress = typeof publicKey.toBase58 === 'function' ? publicKey.toBase58() : String(publicKey || '');
walletAddressNode.textContent = walletAddress || 'подключен, но адрес не распознан';
}
async function getSignatureBytesForCurrentMode(messageBytes) {
if (currentMode === 'extension') {
if (!provider) {
throw new Error('Сначала подключите wallet extension');
}
const signed = await provider.signMessage(messageBytes, 'utf8');
const signature = signed?.signature || signed;
const bytes = signature instanceof Uint8Array ? signature : new Uint8Array(signature || []);
if (bytes.length !== 64) {
throw new Error('Wallet extension вернул подпись некорректной длины');
}
return bytes;
}
const seed32 = base58ToBytes(privateSeedInput.value);
if (seed32.length !== 32) {
throw new Error('Приватный ключ должен быть Base58 seed ровно 32 байта');
}
return signWithSeed32(seed32, messageBytes);
}
async function generatePromoCode() {
const sellerLogin = validateLogin('Логин продавца', sellerLoginInput.value);
const targetLogin = validateLogin('Новый логин', targetLoginInput.value);
const messageBytes = buildPromoMessageBytes(targetLogin);
const signatureBytes = await getSignatureBytesForCurrentMode(messageBytes);
const promoCode = buildPromoCodeString(sellerLogin, signatureBytes);
promoCodeNode.value = promoCode;
copyCodeBtn.disabled = false;
}
function randomSeed32Base58() {
if (!globalThis.crypto?.getRandomValues) {
throw new Error('В браузере нет crypto.getRandomValues');
}
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
return bytesToBase58(seed);
}
async function copyText(text, errorMessage) {
if (!navigator.clipboard?.writeText) {
throw new Error(errorMessage);
}
await navigator.clipboard.writeText(text);
}
document.querySelectorAll('input[name="signMode"]').forEach((node) => {
node.addEventListener('change', () => {
if (!node.checked) return;
currentMode = node.value;
updateModeUi();
setStatus(
currentMode === 'extension'
? 'Режим wallet extension активен.'
: 'Режим ручного Base58 seed активен. Страница может работать полностью оффлайн.',
);
});
});
connectBtn.addEventListener('click', async () => {
connectBtn.disabled = true;
try {
setStatus('Подключение wallet extension...');
await connectWallet();
setStatus(`Кошелёк подключён: ${walletAddress}`, 'success');
} catch (error) {
setStatus(`Ошибка подключения: ${error?.message || 'unknown'}`, 'error');
} finally {
connectBtn.disabled = false;
}
});
generateSeedBtn.addEventListener('click', () => {
try {
const seed = randomSeed32Base58();
privateSeedInput.value = seed;
privateSeedHint.textContent = 'Новый Base58 seed сгенерирован локально в браузере. Храните его как приватный ключ.';
setStatus('Сгенерирован новый Base58 seed 32 bytes.', 'success');
} catch (error) {
setStatus(`Ошибка генерации seed: ${error?.message || 'unknown'}`, 'error');
}
});
copySeedBtn.addEventListener('click', async () => {
try {
const text = String(privateSeedInput.value || '').trim();
if (!text) throw new Error('Сначала сгенерируйте или вставьте seed');
await copyText(text, 'Clipboard API недоступен');
setStatus('Base58 seed скопирован.', 'success');
} catch (error) {
setStatus(`Ошибка копирования seed: ${error?.message || 'unknown'}`, 'error');
}
});
copyCodeBtn.addEventListener('click', async () => {
try {
const text = String(promoCodeNode.value || '').trim();
if (!text) throw new Error('Сначала сгенерируйте promo-код');
await copyText(text, 'Clipboard API недоступен');
setStatus('Promo-код скопирован.', 'success');
} catch (error) {
setStatus(`Ошибка копирования promo-кода: ${error?.message || 'unknown'}`, 'error');
}
});
generateBtn.addEventListener('click', async () => {
generateBtn.disabled = true;
try {
updateMessagePreview();
setStatus('Генерация promo-кода...');
await generatePromoCode();
const modeTitle = currentMode === 'extension' ? 'wallet extension' : 'Base58 seed';
setStatus(`Promo-код готов. Режим: ${modeTitle}.`, 'success');
} catch (error) {
setStatus(`Ошибка генерации: ${error?.message || 'unknown'}`, 'error');
} finally {
generateBtn.disabled = false;
}
});
sellerLoginInput.addEventListener('input', updateMessagePreview);
targetLoginInput.addEventListener('input', updateMessagePreview);
updateModeUi();
updateMessagePreview();
})();
</script>
</body>
</html>
+5 -5
View File
@@ -48,8 +48,8 @@
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
<div class="hint">devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com</div>
<input type="text" id="endpoint" value="https://solana-rpc.publicnode.com" />
<div class="hint">mainnet: https://solana-rpc.publicnode.com · devnet: https://api.devnet.solana.com</div>
</div>
</div>
@@ -61,11 +61,11 @@
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
</div>
<div class="field">
<label>Адрес сервера (URL)</label>
<label>Арес сервера (URL) например shineup.me</label>
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div>
<div class="field">
<label>Серверы синхронизации (sync_servers)</label>
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
<div class="field">
@@ -121,7 +121,7 @@
<div class="sol-ttl">Положите SOL на этот адрес перед регистрацией:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачиваются и создание, и обновление PDA.</div>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть тестовое пополнение DEVNET</button>
<button class="btn-secondary sol-balance-btn" id="btnRefreshBalance" type="button">Показать / обновить баланс device</button>
<div class="sol-balance" id="deviceBalance">Баланс device ещё не запрашивался.</div>
</div>
@@ -16,7 +16,7 @@ import {
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js';
import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from '../../js/deploy-config.js';
const fieldMap = {
masterSecret: 'masterSecret',
@@ -37,9 +37,13 @@ wireDeviceAddressPreview(fieldMap);
$('password').value = '';
$('login').placeholder = defaultServerLogin;
$('serverAddress').placeholder = defaultServerAddress;
$('endpoint').value = defaultSolanaEndpoint;
$('btnTopupDevnet').addEventListener('click', () => {
try {
if (!String($('endpoint').value || '').includes('devnet')) {
throw new Error('Автопополнение доступно только для devnet. Для mainnet пополните device-кошелёк вручную.');
}
openDevnetTopup($('devPub').value);
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
+5
View File
@@ -1,6 +1,7 @@
import { readShineUserPdaByRef } from '../../js/services/shine-user-pda-service.js';
import { bytesToBase58 } from '../../js/services/crypto-utils.js';
import { $, clearStatus, formatBigInt, formatTimestamp, setStatus } from './server-ui-shared.js';
import { defaultSolanaEndpoint } from '../../js/deploy-config.js';
function hex(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
@@ -12,6 +13,10 @@ function base58(bytes) {
return bytesToBase58(data);
}
if ($('endpoint')) {
$('endpoint').value = defaultSolanaEndpoint;
}
function renderRows(rows) {
const container = $('summaryRows');
container.innerHTML = '';
@@ -22,7 +22,7 @@ import {
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js';
import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from '../../js/deploy-config.js';
const fieldMap = {
masterSecret: 'masterSecret',
@@ -100,10 +100,14 @@ wireDeviceAddressPreview(fieldMap);
$('password').value = '';
$('login').placeholder = defaultServerLogin;
$('serverAddress').placeholder = defaultServerAddress;
$('endpoint').value = defaultSolanaEndpoint;
resetExpectedKeysUi();
$('btnTopupDevnet').addEventListener('click', () => {
try {
if (!String($('endpoint').value || '').includes('devnet')) {
throw new Error('Автопополнение доступно только для devnet. Для mainnet пополните device-кошелёк вручную.');
}
openDevnetTopup($('devPub').value);
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
+1 -1
View File
@@ -52,7 +52,7 @@
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
<input type="text" id="endpoint" value="https://solana-rpc.publicnode.com" />
</div>
</div>
+4 -4
View File
@@ -56,7 +56,7 @@
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
<input type="text" id="endpoint" value="https://solana-rpc.publicnode.com" />
</div>
</div>
@@ -86,11 +86,11 @@
<div class="card">
<h2>Новые параметры сервера</h2>
<div class="field">
<label>Новый адрес сервера (URL)</label>
<label>Арес сервера (URL) например shineup.me</label>
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div>
<div class="field">
<label>Новые серверы синхронизации (sync_servers)</label>
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
</div>
@@ -142,7 +142,7 @@
<div class="sol-ttl">Положите SOL на этот адрес перед обновлением:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.</div>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть тестовое пополнение DEVNET</button>
<button class="btn-secondary sol-balance-btn" id="btnRefreshBalance" type="button">Показать / обновить баланс device</button>
<div class="sol-balance" id="deviceBalance">Баланс device ещё не запрашивался.</div>
</div>
+157 -12
View File
@@ -44,6 +44,26 @@
justify-content: flex-end;
}
.chat-header-icon-btn {
min-width: 42px;
width: 42px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.chat-header-call-btn {
border-radius: 12px;
}
.chat-header-menu-btn {
border-radius: 12px;
font-size: 24px;
}
.icon-btn,
.text-btn,
.primary-btn,
@@ -1197,6 +1217,25 @@
gap: 10px;
}
.key-storage-option {
gap: 6px;
}
.key-storage-option__description {
margin: 0;
}
.key-storage-note {
margin: 0;
line-height: 1.45;
}
.key-storage-note--strong {
font-size: 17px;
font-weight: 700;
color: #eef4ff;
}
.key-card {
padding: 12px;
border-radius: var(--radius-md);
@@ -1521,6 +1560,35 @@
word-break: break-word;
}
.bubble-reply-preview {
display: block;
width: 100%;
margin: 0 0 8px;
padding: 8px 10px;
border: 1px solid rgba(142, 173, 232, 0.22);
border-radius: 10px;
background: rgba(11, 22, 41, 0.34);
text-align: left;
color: inherit;
cursor: pointer;
}
.bubble-reply-preview-author {
font-size: 12px;
font-weight: 700;
color: rgba(250, 221, 155, 0.94);
}
.bubble-reply-preview-text {
margin-top: 3px;
font-size: 12px;
line-height: 1.3;
color: rgba(223, 233, 250, 0.86);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-meta {
margin-top: 4px;
display: flex;
@@ -1563,11 +1631,52 @@
.bubble.call-tech {
max-width: 90%;
justify-self: center;
border-radius: 10px;
border: 1px solid rgba(131, 162, 223, 0.32);
background: rgba(40, 55, 84, 0.55);
border-radius: 16px;
border: 1px solid rgba(132, 170, 231, 0.28);
background: rgba(24, 36, 58, 0.72);
color: #dce8ff;
font-size: 12px;
padding: 12px 14px;
}
.bubble-call-card {
display: grid;
gap: 4px;
min-width: min(280px, 72vw);
}
.bubble-call-head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.bubble-call-icon {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(80, 170, 255, 0.18);
border: 1px solid rgba(123, 191, 255, 0.28);
color: #d9ecff;
font-size: 15px;
}
.bubble-call-title {
font-size: 14px;
font-weight: 700;
color: #eef6ff;
}
.bubble-call-line {
font-size: 12px;
line-height: 1.35;
color: rgba(230, 240, 255, 0.94);
}
.bubble-call-line--muted {
color: rgba(188, 207, 238, 0.78);
}
.chat-input {
@@ -2027,20 +2136,23 @@ textarea.input {
}
.network-header-overlay {
position: absolute;
top: max(8px, env(safe-area-inset-top));
position: sticky;
top: max(14px, calc(env(safe-area-inset-top) + 8px));
left: max(8px, env(safe-area-inset-left));
right: max(8px, env(safe-area-inset-right));
box-sizing: border-box;
margin-bottom: 0;
z-index: 12;
pointer-events: none;
padding: 9px 12px 0;
}
.network-header-overlay.page-header {
width: auto;
display: grid;
grid-template-columns: minmax(42px, 1fr) auto minmax(58px, 1fr);
align-items: center;
margin-bottom: 0;
gap: 6px;
gap: 10px;
}
.network-header-overlay .header-left,
@@ -2050,21 +2162,31 @@ textarea.input {
gap: 6px;
}
.network-header-overlay .header-left {
justify-content: flex-start;
}
.network-header-overlay .header-actions {
justify-content: flex-end;
}
.network-header-overlay .icon-btn {
pointer-events: auto;
min-height: 30px;
padding: 5px 8px;
font-size: 11px;
min-height: 32px;
padding: 6px 10px;
font-size: 12px;
border-radius: 10px;
}
.network-header-overlay .page-title {
flex: 1 1 auto;
font-size: 16px;
line-height: 32px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.45);
}
@@ -4541,9 +4663,8 @@ html, body { overflow-x: hidden; }
min-width: 0;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
white-space: pre-line;
overflow: hidden;
text-overflow: ellipsis;
}
.dm-edit-banner__close {
@@ -4823,6 +4944,22 @@ html, body { overflow-x: hidden; }
background: rgba(112, 28, 28, 0.22);
}
.dm-confirm-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(17, 26, 46, 0.62);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #d9e6ff;
font-size: 14px;
}
.dm-confirm-check input {
margin-top: 2px;
}
.speech-actions-top {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -5059,6 +5196,14 @@ html, body { overflow-x: hidden; }
color: #f6deb0;
}
.registration-password-toggle {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border-radius: 14px;
}
.registration-progress {
width: 100%;
height: 10px;
+12
View File
@@ -65,6 +65,18 @@ body::before {
bottom: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
transition: opacity 0.18s ease, transform 0.18s ease;
}
body.chat-keyboard-open .screen-content {
bottom: 0;
padding-bottom: calc(14px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .toolbar-slot {
opacity: 0;
pointer-events: none;
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
}
.connection-retry-banner {