SHA256
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:
+289
-20
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1066
-218
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
+20
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user