import { AuthService } from './services/auth-service.js'; import { listStoredMessages, putStoredMessage, deleteStoredMessage, clearStoredMessages } from './services/message-store.js'; import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js'; import { DEFAULT_SHINE_SERVER_HTTP, DEFAULT_SHINE_SERVER_LOGIN, DEFAULT_SHINE_SERVER_WS, resolveShineServerByServerLogin, } from './services/shine-server-resolver.js'; import { emptyPasswordWords } from './services/password-words.js'; const clone = (value) => JSON.parse(JSON.stringify(value)); const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; const PROFILES_STORAGE_KEY = 'shine-ui-profiles-v1'; const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1'; const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2'; const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1'; const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1'; const CHANNEL_NOTIFY_KEY = 'shine-channels-notify-v1'; const CHANNELS_DEMO_KEY = 'shine-channels-demo'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const MAX_APP_LOG_ENTRIES = 500; const INVALID_SESSION_CODES = new Set([ 'NOT_AUTHENTICATED', 'SESSION_NOT_FOUND', 'SESSION_KEY_NOT_ACTUAL', 'SESSION_OF_ANOTHER_USER', ]); function isLocalPreviewHost() { const host = String(window.location.hostname || '').toLowerCase(); return host === 'localhost' || host === '127.0.0.1' || host === '::1'; } function readLocalWsOverrideUrl() { try { const params = new URLSearchParams(window.location.search); const explicitWsUrl = String(params.get('wsUrl') || '').trim(); if (explicitWsUrl) { if (explicitWsUrl.startsWith('ws://') || explicitWsUrl.startsWith('wss://')) { try { const parsed = new URL(explicitWsUrl); if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; return parsed.toString(); } catch { return explicitWsUrl; } } if (explicitWsUrl.startsWith('http://') || explicitWsUrl.startsWith('https://')) { try { const parsed = new URL(explicitWsUrl); parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:'; if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; return parsed.toString(); } catch { return `${explicitWsUrl.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`; } } return ''; } const value = params.get('localWsPort'); const asNum = Number(value); if (!Number.isFinite(asNum)) return ''; const port = Math.trunc(asNum); if (port <= 0 || port > 65535) return ''; const isHttpsPage = window.location.protocol === 'https:'; const forceInsecureLocal = params.get('allowInsecureLocalWs') === '1'; const scheme = (isHttpsPage && !forceInsecureLocal) ? 'wss' : 'ws'; return `${scheme}://localhost:${port}/ws`; } catch { return ''; } } function inferTunnelWsUrl() { try { const host = String(window.location.host || '').toLowerCase(); const isTunnelHost = ( host.endsWith('.ngrok-free.dev') || host.endsWith('.ngrok.io') || host.endsWith('.trycloudflare.com') ); if (!isTunnelHost) return ''; const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; return `${scheme}://${window.location.host}/ws`; } catch { return ''; } } const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl(); const DEFAULT_SOLANA_SERVER = SOLANA_ENDPOINT_DEFAULT; const DEFAULT_SHINE_SERVER = DEFAULT_SHINE_SERVER_WS; const DEFAULT_SHINE_SERVER_LOGIN_VALUE = DEFAULT_SHINE_SERVER_LOGIN; const DEFAULT_SHINE_SERVER_HTTP_VALUE = DEFAULT_SHINE_SERVER_HTTP; const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; function normalizeStoredSolanaServer(value) { const raw = String(value || '').trim(); if (!raw) return DEFAULT_SOLANA_SERVER; return raw.replace(/\/+$/u, ''); } export function normalizeDmChatId(value) { return String(value || '').trim().toLowerCase(); } function normalizeToolsSettings(rawTools) { const source = rawTools && typeof rawTools === 'object' ? rawTools : {}; const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {}; return { textToSpeech: { provider: String(tts.provider || 'openai'), quality: String(tts.quality || 'medium'), voice: String(tts.voice || ''), piperBaseUrl: String(tts.piperBaseUrl || 'http://127.0.0.1:5000'), externalBaseUrl: String(tts.externalBaseUrl || ''), apiKey: String(tts.apiKey || ''), model: String(tts.model || ''), }, }; } function normalizeProfileLogin(value) { return String(value || '').trim().toLowerCase(); } function loadProfileStoreRaw() { try { const raw = localStorage.getItem(PROFILES_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((item) => item && normalizeProfileLogin(item.login) && String(item.sessionId || '').trim()); } catch { return []; } } function persistProfileStoreRaw(items) { try { localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(Array.isArray(items) ? items : [])); } catch { // ignore storage errors } } function getActiveProfileLoginRaw() { try { return normalizeProfileLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY)); } catch { return ''; } } function setActiveProfileLoginRaw(login) { const normalized = normalizeProfileLogin(login); try { if (normalized) localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, normalized); else localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY); } catch { // ignore storage errors } } function profileEntrySettingsSnapshot(settings = {}) { return { solanaServer: String(settings.solanaServer || ''), shineServer: String(settings.shineServer || ''), shineServerLogin: String(settings.shineServerLogin || ''), shineServerHttp: String(settings.shineServerHttp || ''), arweaveServer: String(settings.arweaveServer || ''), callPreflightTimeoutMs: Number(settings.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS), remoteAddBlockSessionId: String(settings.remoteAddBlockSessionId || ''), }; } function upsertSavedProfileInternal({ login, sessionId, isLocalDemo = false, entrySettings = null } = {}) { const normalized = normalizeProfileLogin(login); const cleanSessionId = String(sessionId || '').trim(); if (!normalized || !cleanSessionId) return; const items = loadProfileStoreRaw(); const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized); const previous = index >= 0 ? items[index] : {}; const next = { ...previous, login: String(login || '').trim(), sessionId: cleanSessionId, isLocalDemo: Boolean(isLocalDemo), entrySettings: entrySettings ? profileEntrySettingsSnapshot(entrySettings) : (previous.entrySettings || {}), updatedAtMs: Date.now(), }; if (index >= 0) items[index] = next; else items.push(next); persistProfileStoreRaw(items); setActiveProfileLoginRaw(normalized); } function migrateLegacySessionToProfileStore() { const existing = loadProfileStoreRaw(); if (existing.length) return; try { const raw = localStorage.getItem(SESSION_STORAGE_KEY); if (!raw) return; const legacy = JSON.parse(raw); if (!legacy?.login || !legacy?.sessionId) return; let entrySettings = {}; try { entrySettings = JSON.parse(localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY) || '{}') || {}; } catch {} upsertSavedProfileInternal({ login: legacy.login, sessionId: legacy.sessionId, isLocalDemo: legacy.isLocalDemo, entrySettings, }); } catch { // ignore migration errors } } function loadStoredSession() { migrateLegacySessionToProfileStore(); const profiles = loadProfileStoreRaw(); if (profiles.length) { const activeLogin = getActiveProfileLoginRaw(); const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0]; if (active) { setActiveProfileLoginRaw(active.login); return { isAuthorized: false, isLocalDemo: Boolean(active.isLocalDemo), login: String(active.login || '').trim(), sessionId: String(active.sessionId || '').trim(), }; } } try { const raw = localStorage.getItem(SESSION_STORAGE_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } function loadStoredReactions() { try { const raw = localStorage.getItem(REACTIONS_STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; return parsed; } catch { return {}; } } function persistStoredReactions(reactions) { try { localStorage.setItem(REACTIONS_STORAGE_KEY, JSON.stringify(reactions || {})); } catch { // ignore storage errors } } function persistSession(session) { try { localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); } catch { // ignore quota/storage errors for prototype } } function clearStoredSession() { try { localStorage.removeItem(SESSION_STORAGE_KEY); } catch { // ignore } } function loadStoredEntrySettings() { migrateLegacySessionToProfileStore(); const profiles = loadProfileStoreRaw(); if (profiles.length) { const activeLogin = getActiveProfileLoginRaw(); const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0]; if (active?.entrySettings && typeof active.entrySettings === 'object') { return active.entrySettings; } } try { const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return null; return parsed; } catch { return null; } } function persistEntrySettings(settings) { try { const payload = { language: String(settings?.language || 'ru'), 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'), arweaveServer: String(settings?.statuses?.arweaveServer || 'idle'), }, tools: normalizeToolsSettings(settings?.tools), }; localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload)); const activeLogin = getActiveProfileLoginRaw(); if (activeLogin) { const profiles = loadProfileStoreRaw(); const index = profiles.findIndex((item) => normalizeProfileLogin(item.login) === activeLogin); if (index >= 0) { profiles[index] = { ...profiles[index], entrySettings: profileEntrySettingsSnapshot(payload), updatedAtMs: Date.now(), }; persistProfileStoreRaw(profiles); } } } catch { // ignore storage errors } } export function clearBrowserClientData() { const localKeys = [ SESSION_STORAGE_KEY, REACTIONS_STORAGE_KEY, WEB_PUSH_SUBSCRIPTION_KEY, CHANNEL_NOTIFY_KEY, CHANNELS_DEMO_KEY, ]; localKeys.forEach((key) => { try { localStorage.removeItem(key); } catch { // ignore } }); try { sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY); } catch { // ignore } } function createInitialState({ withStoredSession = true } = {}) { const storedSession = withStoredSession ? loadStoredSession() : null; const storedLocalDemo = Boolean(storedSession?.isLocalDemo && isLocalPreviewHost()); const storedReactions = loadStoredReactions(); const storedEntrySettings = loadStoredEntrySettings(); const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER; return { chats: {}, contacts: [], appLog: [], incomingDedup: {}, knownMessageKeys: {}, pendingOutgoingReadByBaseKey: {}, pendingIncomingReadByBaseKey: {}, outgoingTempSeq: 1, notificationsTab: 'replies', notificationUnreadTotal: 0, userCounters: { dmUnreadCount: 0, channelsUnreadCount: 0, notificationsUnreadCount: 0, notifications: { replies: 0, connections: 0, events: 0 } }, pageLabelCollapsed: false, session: { isAuthorized: storedLocalDemo, isLocalDemo: storedLocalDemo, login: storedSession?.login || '', sessionId: storedSession?.sessionId || '', storagePwdInMemory: '', }, startHint: '', entrySettings: { language: String(storedEntrySettings?.language || 'ru'), 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'), arweaveServer: String(storedEntrySettings?.statuses?.arweaveServer || 'idle'), }, tools: normalizeToolsSettings(storedEntrySettings?.tools), }, registrationDraft: { flowType: '', login: '', password: '', passwordMode: 'single', passwordWords: emptyPasswordWords(), passwordWordsLinked: false, usePromoCode: false, promoCode: '', sessionId: '', storagePwd: '', pendingKeyBundle: null, pendingSessionMaterial: null, preGeneratedKeyBundle: null, }, registrationHelp: { selectedTopic: 'keys-storage', }, loginDraft: { login: storedSession?.login || '', password: '', passwordMode: 'single', passwordWords: emptyPasswordWords(), }, registrationPayment: { walletAddress: '', balanceSOL: '0.0000', }, keyStorage: { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', clientKey: 'Client key хранится в зашифрованном виде', saveRoot: true, saveBlockchain: true, saveClient: true, }, deviceConnect: { root: true, blockchain: true, client: true, }, authUi: { busy: false, error: '', info: '', }, authReturnHash: '', sessions: [], channelsFeed: null, channelsIndex: {}, localChannelPosts: {}, messageReactions: storedReactions, }; } 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; function parseMessageTimeFromKey(rawKey) { const value = String(rawKey || '').trim(); if (!value) return 0; const parts = value.split('|'); if (parts.length < 4) return 0; const timeMs = Number(parts[2] || 0); if (!Number.isFinite(timeMs) || timeMs <= 0) return 0; return Math.trunc(timeMs); } function resolveChatMessageTimeMs(row) { const fromBaseKey = parseMessageTimeFromKey(row?.baseKey); if (fromBaseKey > 0) return fromBaseKey; const fromMessageKey = parseMessageTimeFromKey(row?.messageKey); if (fromMessageKey > 0) return fromMessageKey; const fromCreatedAt = Number(row?.createdAtMs || row?.ts || 0); if (Number.isFinite(fromCreatedAt) && fromCreatedAt > 0) return Math.trunc(fromCreatedAt); const tempId = String(row?.tempId || '').trim(); if (tempId.startsWith('tmp-')) { const parts = tempId.split('-'); const ts = Number(parts[1] || 0); if (Number.isFinite(ts) && ts > 0) return Math.trunc(ts); } return 0; } function stableMessageOrderKey(row) { const key = String(row?.messageKey || '').trim(); if (key) return `mk:${key}`; const tmp = String(row?.tempId || '').trim(); if (tmp) return `tmp:${tmp}`; const base = String(row?.baseKey || '').trim(); if (base) return `bk:${base}`; const text = String(row?.text || '').trim(); return `txt:${text}`; } function sortChatMessagesInPlace(chatId) { const list = getChatMessages(chatId); list.sort((a, b) => { const ta = resolveChatMessageTimeMs(a); const tb = resolveChatMessageTimeMs(b); if (ta !== tb) return ta - tb; const ka = stableMessageOrderKey(a); const kb = stableMessageOrderKey(b); const byKey = ka.localeCompare(kb, 'ru'); if (byKey !== 0) return byKey; if ((a?.from || '') !== (b?.from || '')) { return (a?.from === 'in') ? -1 : 1; } return 0; }); } function persistMessageRecord(chatId, row) { const normalizedChatId = normalizeDmChatId(chatId); if (!normalizedChatId || !row?.messageKey) return; const resolvedTs = resolveChatMessageTimeMs(row); void putStoredMessage({ messageKey: row.messageKey, ownerLogin: String(state.session.login || '').trim().toLowerCase(), chatId: normalizedChatId, from: row.from || 'in', text: String(row.text || ''), baseKey: String(row.baseKey || ''), messageType: Number(row.messageType || 0), rawBlobB64: String(row.rawBlobB64 || ''), revisionTimeMs: Number(row.revisionTimeMs || 0), unread: Boolean(row.unread), firstTick: Boolean(row.firstTick), secondTick: Boolean(row.secondTick), readAtMs: Number(row.readAtMs || 0), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), deliveryState: String(row.deliveryState || ''), ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } function removeStoredMessageRecord(messageKey) { if (!messageKey) return; void deleteStoredMessage(messageKey, state.session.login).catch(() => {}); } export async function hydrateMessagesFromStore() { try { const rows = await listStoredMessages(state.session.login); const touchedChats = new Set(); rows .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) .forEach((row) => { const chatId = normalizeDmChatId(row?.chatId); const messageKey = String(row?.messageKey || '').trim(); if (!chatId || !messageKey) return; if (state.knownMessageKeys[messageKey]) return; state.knownMessageKeys[messageKey] = true; touchedChats.add(chatId); getChatMessages(chatId).push({ from: row.from === 'out' ? 'out' : 'in', text: String(row.text || ''), messageKey, baseKey: String(row.baseKey || ''), messageType: Number(row.messageType || 0), rawBlobB64: String(row.rawBlobB64 || ''), revisionTimeMs: Number(row.revisionTimeMs || 0), unread: Boolean(row.unread), firstTick: Boolean(row.firstTick), secondTick: Boolean(row.secondTick), readAtMs: Number(row.readAtMs || 0), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), deliveryState: String(row.deliveryState || ''), createdAtMs: Number(row.ts || 0), }); }); touchedChats.forEach((chatId) => sortChatMessagesInPlace(chatId)); } catch { // ignore broken storage } } export function getChatMessages(chatId) { const normalizedChatId = normalizeDmChatId(chatId); if (!normalizedChatId) return []; if (!state.chats[normalizedChatId]) { state.chats[normalizedChatId] = []; } return state.chats[normalizedChatId]; } export function addChatMessage(chatId, text) { const message = text.trim(); if (!message) return; getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false, createdAtMs: Date.now(), }); sortChatMessagesInPlace(chatId); } export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'system' } = {}) { const message = String(text || '').trim(); if (!message) return; getChatMessages(chatId).push({ from: from === 'in' ? 'in' : 'out', text: message, kind: String(kind || 'system'), unread: from === 'in', firstTick: from !== 'in', secondTick: false, createdAtMs: Date.now(), }); sortChatMessagesInPlace(chatId); } export function addIncomingMessage(chatId, text, messageId = '') { const msg = text?.trim(); if (!msg) return false; if (messageId && state.incomingDedup[messageId]) return false; if (messageId) state.incomingDedup[messageId] = true; getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true, createdAtMs: Date.now(), }); sortChatMessagesInPlace(chatId); return true; } export function addOutgoingPendingMessage(chatId, text) { const msg = String(text || '').trim(); if (!msg) return null; const tempId = `tmp-${Date.now()}-${state.outgoingTempSeq++}`; getChatMessages(chatId).push({ from: 'out', text: msg, tempId, firstTick: false, secondTick: false, unread: false, createdAtMs: Date.now(), }); sortChatMessagesInPlace(chatId); return tempId; } export function markOutgoingSent(tempId, { messageKey = '', baseKey = '', deliveryState = 'accepted', } = {}) { if (!tempId) return; const keys = Object.keys(state.chats || {}); keys.forEach((chatId) => { const list = getChatMessages(chatId); const row = list.find((item) => item?.tempId === tempId); if (!row) return; row.firstTick = true; row.messageKey = messageKey || row.messageKey || ''; row.baseKey = baseKey || row.baseKey || ''; row.deliveryState = String(deliveryState || row.deliveryState || 'accepted'); if (messageKey) { state.knownMessageKeys[messageKey] = true; persistMessageRecord(chatId, row); } sortChatMessagesInPlace(chatId); }); } export function markOutgoingDeliveryState({ outgoingKey = '', baseKey = '', deliveryState = '', } = {}) { const normalizedOutgoingKey = String(outgoingKey || '').trim(); const normalizedBaseKey = String(baseKey || '').trim(); let changed = false; Object.keys(state.chats || {}).forEach((chatId) => { getChatMessages(chatId).forEach((row) => { if (row?.from !== 'out') return; const matches = ( (normalizedOutgoingKey && String(row.messageKey || '') === normalizedOutgoingKey) || (normalizedBaseKey && String(row.baseKey || '') === normalizedBaseKey) ); if (!matches) return; row.deliveryState = String(deliveryState || row.deliveryState || 'accepted'); if (row.deliveryState === 'delivered') { row.firstTick = true; } persistMessageRecord(chatId, row); changed = true; }); }); return changed; } export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) { if (!baseKey) return; const normalizedReadAtMs = Number(readAtMs || 0); 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; if (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0) { row.readAtMs = normalizedReadAtMs; } persistMessageRecord(chatId, row); } }); }); if (matched) { delete state.pendingOutgoingReadByBaseKey[baseKey]; } else { state.pendingOutgoingReadByBaseKey[baseKey] = (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0) ? normalizedReadAtMs : 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) { if (!baseKey) return; const keys = Object.keys(state.chats || {}); keys.forEach((chatId) => { const list = getChatMessages(chatId); list.forEach((row) => { if (row?.from !== 'in') return; if (row.baseKey === baseKey) { row.readReceiptSent = true; persistMessageRecord(chatId, row); } }); }); } export function addSignedMessageToChat({ chatId, messageKey, baseKey = '', from = 'in', text = '', messageType = 1, unread = false, readAtMs = 0, rawBlobB64 = '', refBaseKey = '', revisionTimeMs = 0, deliveryState = '', deleted = false, } = {}) { const normalizedChatId = normalizeDmChatId(chatId); const id = String(messageKey || '').trim(); if (!normalizedChatId || !id) return false; const list = getChatMessages(normalizedChatId); const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id); const existing = existingIndex >= 0 ? list[existingIndex] : null; const nextRevision = Number(revisionTimeMs || 0); const currentRevision = Number(existing?.revisionTimeMs || 0); if (existing && Number.isFinite(currentRevision) && nextRevision < currentRevision) { return false; } if (deleted) { if (existingIndex >= 0) { list.splice(existingIndex, 1); removeStoredMessageRecord(id); sortChatMessagesInPlace(normalizedChatId); return true; } return false; } state.knownMessageKeys[id] = true; const row = existing || {}; row.from = from === 'out' ? 'out' : 'in'; row.text = String(text || ''); row.messageKey = id; row.baseKey = String(baseKey || ''); row.messageType = Number(messageType || 0); row.rawBlobB64 = String(rawBlobB64 || ''); row.revisionTimeMs = nextRevision; row.deliveryState = String(deliveryState || existing?.deliveryState || (row.from === 'out' ? 'accepted' : '')); row.unread = row.from === 'in' ? Boolean(unread) : false; row.refBaseKey = String(refBaseKey || ''); row.firstTick = row.from === 'out'; row.secondTick = Boolean(existing?.secondTick); row.readAtMs = Number(existing?.readAtMs || 0); row.readReceiptSent = Boolean(existing?.readReceiptSent); if (row.baseKey && row.from === 'out' && state.pendingOutgoingReadByBaseKey[row.baseKey]) { row.secondTick = true; const pendingReadAtMs = Number(state.pendingOutgoingReadByBaseKey[row.baseKey] || 0); if (Number.isFinite(pendingReadAtMs) && pendingReadAtMs > 0) { row.readAtMs = pendingReadAtMs; } 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); } const nextReadAtMs = Number(readAtMs || 0); if (Number.isFinite(nextReadAtMs) && nextReadAtMs > 0) { row.readAtMs = nextReadAtMs; if (row.from === 'out') { row.secondTick = true; } } sortChatMessagesInPlace(normalizedChatId); persistMessageRecord(normalizedChatId, row); 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' && row?.unread) { row.unread = false; persistMessageRecord(normalizedChatId, row); changed += 1; } }); return changed; } export function setContacts(list) { state.contacts = Array.isArray(list) ? [...list] : []; } function toText(value) { if (typeof value === 'string') return value; if (value == null) return ''; try { return JSON.stringify(value); } catch { return String(value); } } export function addAppLogEntry({ level = 'info', source = 'ui', message = '', details = '', } = {}) { const cleanMessage = String(message || '').trim(); if (!cleanMessage) return; const cleanLevel = String(level || 'info').trim().toLowerCase(); const normalizedLevel = (cleanLevel === 'error' || cleanLevel === 'warn') ? cleanLevel : 'info'; state.appLog.push({ id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`, ts: Date.now(), level: normalizedLevel, source: String(source || 'ui').trim() || 'ui', message: cleanMessage, details: toText(details), }); if (state.appLog.length > MAX_APP_LOG_ENTRIES) { state.appLog.splice(0, state.appLog.length - MAX_APP_LOG_ENTRIES); } } export function getAppLogEntries() { return [...state.appLog]; } export function clearAppLogEntries() { state.appLog = []; } export function togglePageLabel() { state.pageLabelCollapsed = !state.pageLabelCollapsed; } export function ensureChat(chatId) { return getChatMessages(chatId); } export function checkServerAvailability(address) { const normalized = String(address || '').trim().toLowerCase(); if (!normalized) return 'unavailable'; return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable'; } export function saveEntryLanguage(language) { state.entrySettings.language = String(language || 'ru'); persistEntrySettings(state.entrySettings); } export async function saveEntrySettings(nextSettings) { 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 || ''; let resolvedShineServerHttp = String(state.entrySettings.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE).trim() || DEFAULT_SHINE_SERVER_HTTP_VALUE; if (!forcedShineServer) { const resolved = await resolveShineServerByServerLogin({ serverLogin: nextShineServerLogin, solanaEndpoint: nextSolanaServer, }); forcedShineServer = resolved.wsUrl; resolvedShineServerHttp = resolved.httpBase; } state.entrySettings = { ...state.entrySettings, ...nextSettings, solanaServer: nextSolanaServer, shineServer: forcedShineServer, shineServerLogin: nextShineServerLogin, shineServerHttp: resolvedShineServerHttp, statuses: { ...state.entrySettings.statuses, ...(nextSettings.statuses || {}), }, 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}`; } export function clearStartHint() { state.startHint = ''; } export function setAuthBusy(flag) { state.authUi.busy = flag; } export function setAuthError(message) { state.authUi.error = message || ''; } export function setAuthInfo(message) { state.authUi.info = message || ''; } export function clearAuthMessages() { state.authUi.error = ''; state.authUi.info = ''; } export function authorizeSession({ login = state.session.login, sessionId = state.session.sessionId, storagePwd = state.session.storagePwdInMemory, isLocalDemo = false, } = {}) { const localDemo = Boolean(isLocalDemo && isLocalPreviewHost()); state.session.isAuthorized = true; state.session.isLocalDemo = localDemo; state.session.login = login; state.session.sessionId = sessionId; state.session.storagePwdInMemory = storagePwd; persistSession({ isAuthorized: true, isLocalDemo: localDemo, login, sessionId, }); upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings }); authService.setActiveSessionContext({ login, sessionId }); state.startHint = ''; if (onSessionAuthorized) { onSessionAuthorized(); } } export function isLocalDemoAvailable() { return isLocalPreviewHost(); } export function authorizeLocalDemoSession() { if (!isLocalPreviewHost()) return false; authorizeSession({ login: 'local-tester', sessionId: 'local-ui-demo', storagePwd: '', isLocalDemo: true, }); return true; } export function setSessionResetHandler(handler) { onSessionReset = typeof handler === 'function' ? handler : null; } export function setSessionAuthorizedHandler(handler) { onSessionAuthorized = typeof handler === 'function' ? handler : null; } export function isSessionInvalidError(error) { return INVALID_SESSION_CODES.has(error?.code); } export async function refreshSessions() { state.sessions = await authService.listSessions(); return state.sessions; } export function resetRegistrationFlow() { const next = createInitialState(); state.registrationDraft = next.registrationDraft; state.registrationHelp = next.registrationHelp; state.registrationPayment = next.registrationPayment; state.keyStorage = next.keyStorage; } function resetStateForSignedOut() { const next = createInitialState({ withStoredSession: false }); state.chats = next.chats; state.contacts = next.contacts; 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; state.session = next.session; state.startHint = next.startHint; state.entrySettings = next.entrySettings; state.registrationDraft = next.registrationDraft; state.loginDraft = next.loginDraft; state.registrationPayment = next.registrationPayment; state.keyStorage = next.keyStorage; state.deviceConnect = next.deviceConnect; state.authUi = next.authUi; state.sessions = next.sessions; state.channelsFeed = next.channelsFeed; state.channelsIndex = next.channelsIndex; state.localChannelPosts = next.localChannelPosts; state.messageReactions = next.messageReactions; } async function tryCloseCurrentSessionOnServer() { const currentSessionId = String(state.session.sessionId || '').trim(); if (!state.session.isAuthorized || state.session.isLocalDemo || !currentSessionId) return; try { await authService.closeSession(currentSessionId); } catch (error) { addAppLogEntry({ level: 'warn', source: 'session', message: 'Не удалось завершить текущую сессию на сервере', details: { sessionId: currentSessionId, error: error?.message || 'unknown' }, }); } } export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) { const signedOutLogin = String(state.session.login || '').trim(); if (closeServerSession) { await tryCloseCurrentSessionOnServer(); } clearStoredSession(); resetStateForSignedOut(); await clearStoredMessages(signedOutLogin).catch(() => {}); authService.close(); authService.clearActiveSessionContext(); if (infoMessage) { state.startHint = infoMessage; } try { await authService.reconnect(state.entrySettings.shineServer); } catch { // ignore reconnect errors on sign out } if (notifySessionReset && onSessionReset) { onSessionReset(); } } export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) { await terminateCurrentSession({ infoMessage, closeServerSession: true }); } export function getSavedProfiles() { migrateLegacySessionToProfileStore(); const activeLogin = getActiveProfileLoginRaw(); return loadProfileStoreRaw().map((item) => ({ login: String(item.login || '').trim(), sessionId: String(item.sessionId || '').trim(), isLocalDemo: Boolean(item.isLocalDemo), isActive: normalizeProfileLogin(item.login) === activeLogin, entrySettings: item.entrySettings || {}, })); } export async function switchToSavedProfile(login) { const targetLogin = normalizeProfileLogin(login); const target = loadProfileStoreRaw().find((item) => normalizeProfileLogin(item.login) === targetLogin); if (!target) throw new Error('Профиль не найден на этом устройстве'); if (targetLogin === normalizeProfileLogin(state.session.login)) return target; const origin = { login: String(state.session.login || '').trim(), sessionId: String(state.session.sessionId || '').trim(), isLocalDemo: Boolean(state.session.isLocalDemo), server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(), }; const targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); // В каждый момент времени держим только один WebSocket: сначала полностью // закрываем transport активного профиля, затем создаём новый для target. authService.resetConnection(targetServer, { clearSessionContext: true }); try { const resumed = await authService.resumeSession(target.login, target.sessionId); target.login = resumed.login || target.login; target.sessionId = resumed.sessionId || target.sessionId; target.updatedAtMs = Date.now(); authService.setActiveSessionContext({ login: target.login, sessionId: target.sessionId }); persistProfileStoreRaw(loadProfileStoreRaw().map((item) => ( normalizeProfileLogin(item.login) === targetLogin ? target : item ))); setActiveProfileLoginRaw(target.login); persistSession({ isAuthorized: true, isLocalDemo: Boolean(target.isLocalDemo), login: target.login, sessionId: target.sessionId }); if (target.entrySettings && typeof target.entrySettings === 'object') { persistEntrySettings({ ...state.entrySettings, ...target.entrySettings }); } return target; } catch (switchError) { // Если новый профиль не поднялся — создаём новый socket обратно для старого. try { authService.resetConnection(origin.server, { clearSessionContext: true }); if (origin.login && origin.sessionId && !origin.isLocalDemo) { const restored = await authService.resumeSession(origin.login, origin.sessionId); authService.setActiveSessionContext({ login: restored?.login || origin.login, sessionId: restored?.sessionId || origin.sessionId, }); } else if (origin.login) { authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId }); } } catch (restoreError) { console.warn('[profiles] failed to restore previous profile connection after switch failure', restoreError); } throw switchError; } } async function closeSavedProfileSessionBestEffort(profile) { if (!profile || profile.isLocalDemo) return; const cleanSessionId = String(profile.sessionId || '').trim(); if (!cleanSessionId) return; const normalized = normalizeProfileLogin(profile.login); const activeNormalized = normalizeProfileLogin(state.session.login); if (normalized === activeNormalized && state.session.isAuthorized) { try { await authService.closeSession(cleanSessionId); } catch {} return; } const origin = { login: String(state.session.login || '').trim(), sessionId: String(state.session.sessionId || '').trim(), isLocalDemo: Boolean(state.session.isLocalDemo), server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(), }; const targetServer = String(profile?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); try { authService.resetConnection(targetServer, { clearSessionContext: true }); await authService.resumeSession(profile.login, cleanSessionId); await authService.closeSession(cleanSessionId); } catch { // Закрытие профиля на устройстве не блокируем из-за недоступного сервера. } finally { try { authService.resetConnection(origin.server, { clearSessionContext: true }); if (origin.login && origin.sessionId && !origin.isLocalDemo) { const restored = await authService.resumeSession(origin.login, origin.sessionId); authService.setActiveSessionContext({ login: restored?.login || origin.login, sessionId: restored?.sessionId || origin.sessionId, }); } else if (origin.login) { authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId }); } } catch (error) { console.warn('[profiles] failed to restore active profile after closing another profile', error); } } } export async function closeSavedProfile(login) { const normalized = normalizeProfileLogin(login); const items = loadProfileStoreRaw(); const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized); if (index < 0) return { closed: false, nextProfile: null }; const target = items[index]; await closeSavedProfileSessionBestEffort(target); await clearStoredMessages(target.login).catch(() => {}); const nextItems = items.filter((_, itemIndex) => itemIndex !== index); persistProfileStoreRaw(nextItems); const wasActive = normalized === getActiveProfileLoginRaw(); if (!wasActive) return { closed: true, nextProfile: null }; const next = nextItems[index] || nextItems[index - 1] || nextItems[0] || null; if (!next) { setActiveProfileLoginRaw(''); clearStoredSession(); authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); return { closed: true, nextProfile: null }; } setActiveProfileLoginRaw(next.login); persistSession({ isAuthorized: true, isLocalDemo: Boolean(next.isLocalDemo), login: next.login, sessionId: next.sessionId }); if (next.entrySettings && typeof next.entrySettings === 'object') { persistEntrySettings({ ...state.entrySettings, ...next.entrySettings }); } return { closed: true, nextProfile: next }; } export async function closeAllSavedProfiles() { const items = loadProfileStoreRaw(); for (const item of items) { await clearStoredMessages(item.login).catch(() => {}); if (item.isLocalDemo || !String(item.sessionId || '').trim()) continue; const targetServer = String(item?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); try { authService.resetConnection(targetServer, { clearSessionContext: true }); await authService.resumeSession(item.login, item.sessionId); await authService.closeSession(item.sessionId); } catch { // Все локальные профили всё равно закрываем, даже если один сервер недоступен. } } persistProfileStoreRaw([]); setActiveProfileLoginRaw(''); clearStoredSession(); authService.close(); authService.clearActiveSessionContext(); } export function isAddingProfileLogin() { return state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles'; } export async function prepareAddProfileLogin() { state.loginDraft.login = ''; state.loginDraft.password = ''; clearAuthMessages(); // While an existing profile stays authorized, PRE_AUTH login pages are normally // blocked by app.js. This return target also acts as an explicit add-profile mode. state.authReturnHash = '/profiles'; // Не пытаемся авторизовать второй login через уже authenticated socket. // Старую серверную сессию НЕ закрываем: закрываем только локальный transport. authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); } export async function cancelAddProfileLogin() { const wasAddingProfile = isAddingProfileLogin(); const shouldRestoreActiveConnection = wasAddingProfile && state.session.isAuthorized && Boolean(String(state.session.login || '').trim()) && Boolean(String(state.session.sessionId || '').trim()); state.authReturnHash = ''; state.loginDraft.login = ''; state.loginDraft.password = ''; resetRegistrationFlow(); clearAuthMessages(); if (shouldRestoreActiveConnection) { try { authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); await authService.resumeSession(state.session.login, state.session.sessionId); authService.setActiveSessionContext({ login: state.session.login, sessionId: state.session.sessionId }); } catch (error) { console.warn('[profiles] failed to restore active profile connection after cancelling add-profile flow', error); } } return wasAddingProfile; } export function consumeAuthReturnPage(fallback = 'profile-view') { const nextHash = String(state.authReturnHash || '').trim(); state.authReturnHash = ''; if (nextHash.startsWith('/')) return nextHash.slice(1) || fallback; return fallback; } export function refreshRegistrationBalance() { const next = (0.005 + Math.random() * 0.03).toFixed(4); state.registrationPayment.balanceSOL = next; return next; } export function setChannelsFeed(feed, index) { state.channelsFeed = feed || null; state.channelsIndex = index || {}; } export function getLocalChannelPosts(channelId) { if (!channelId) return []; if (!state.localChannelPosts[channelId]) { state.localChannelPosts[channelId] = []; } return state.localChannelPosts[channelId]; } export function addLocalChannelPost(channelId, post) { if (!channelId) return; const text = post?.body?.trim(); if (!text) return; getLocalChannelPosts(channelId).push({ title: post.title || `${state.session.login || 'Вы'} • сейчас`, body: text, }); } function makeMessageReactionKey(messageRef, login = state.session.login) { const bch = String(messageRef?.blockchainName || '').trim(); const blockNumber = Number(messageRef?.blockNumber); const blockHash = String(messageRef?.blockHash || '').trim().toLowerCase(); const cleanLogin = String(login || '').trim().toLowerCase(); if (!cleanLogin || !bch || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return ''; return `${cleanLogin}|${bch}|${blockNumber}|${blockHash}`; } export function getMessageReactionState(messageRef) { const key = makeMessageReactionKey(messageRef); if (!key) return ''; return state.messageReactions[key] || ''; } export function setMessageReactionState(messageRef, nextState) { const key = makeMessageReactionKey(messageRef); if (!key) return; const normalized = String(nextState || '').trim().toLowerCase(); if (normalized === 'liked' || normalized === 'unliked') { state.messageReactions[key] = normalized; persistStoredReactions(state.messageReactions); return; } delete state.messageReactions[key]; persistStoredReactions(state.messageReactions); }