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'; const clone = (value) => JSON.parse(JSON.stringify(value)); const SESSION_STORAGE_KEY = 'shine-ui-current-session-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 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; const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; function normalizeToolsSettings(rawTools) { const source = rawTools && typeof rawTools === 'object' ? rawTools : {}; const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {}; const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {}; return { speechToText: { provider: String(stt.provider || 'openai'), baseUrl: String(stt.baseUrl || DEFAULT_OPENAI_BASE_URL), apiKey: String(stt.apiKey || ''), quality: String(stt.quality || 'medium'), model: String(stt.model || ''), }, 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 loadStoredSession() { 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() { 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: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER), 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)), 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)); } 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 storedReactions = loadStoredReactions(); const storedEntrySettings = loadStoredEntrySettings(); const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER; return { chats: {}, contacts: [], appLog: [], incomingDedup: {}, knownMessageKeys: {}, outgoingTempSeq: 1, notificationsTab: 'replies', pageLabelCollapsed: false, session: { isAuthorized: false, login: storedSession?.login || '', sessionId: storedSession?.sessionId || '', storagePwdInMemory: '', }, startHint: '', entrySettings: { language: String(storedEntrySettings?.language || 'ru'), solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER), 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)), 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: '', sessionId: '', storagePwd: '', pendingKeyBundle: null, pendingSessionMaterial: null, preGeneratedKeyBundle: null, }, loginDraft: { login: storedSession?.login || '', password: '', }, registrationPayment: { walletAddress: '', balanceSOL: '0.0000', }, keyStorage: { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', deviceKey: 'Ключ device хранится в зашифрованном виде', saveRoot: false, saveBlockchain: true, saveDevice: true, }, deviceConnect: { root: true, blockchain: true, device: 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); 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) { if (!chatId || !row?.messageKey) return; const resolvedTs = resolveChatMessageTimeMs(row); void putStoredMessage({ messageKey: row.messageKey, chatId, 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), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), attachments: Array.isArray(row.attachments) ? row.attachments : [], ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } function removeStoredMessageRecord(messageKey) { if (!messageKey) return; void deleteStoredMessage(messageKey).catch(() => {}); } export async function hydrateMessagesFromStore() { try { const rows = await listStoredMessages(); const touchedChats = new Set(); rows .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) .forEach((row) => { const chatId = String(row?.chatId || '').trim(); 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), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), attachments: Array.isArray(row.attachments) ? row.attachments : [], createdAtMs: Number(row.ts || 0), }); }); touchedChats.forEach((chatId) => sortChatMessagesInPlace(chatId)); } catch { // ignore broken storage } } export function getChatMessages(chatId) { if (!state.chats[chatId]) { state.chats[chatId] = []; } return state.chats[chatId]; } 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 = '' } = {}) { 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 || ''; if (messageKey) { state.knownMessageKeys[messageKey] = true; persistMessageRecord(chatId, row); } sortChatMessagesInPlace(chatId); }); } export function markOutgoingReadByBaseKey(baseKey) { if (!baseKey) return; const keys = Object.keys(state.chats || {}); keys.forEach((chatId) => { const list = getChatMessages(chatId); list.forEach((row) => { if (row?.from !== 'out') return; if (row.baseKey === baseKey) { row.secondTick = true; persistMessageRecord(chatId, row); } }); }); } export function markIncomingReadByBaseKey(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.unread = false; row.readReceiptSent = true; persistMessageRecord(chatId, row); } }); }); } 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, rawBlobB64 = '', refBaseKey = '', revisionTimeMs = 0, attachments = [], deleted = false, } = {}) { const id = String(messageKey || '').trim(); if (!chatId || !id) return false; const list = getChatMessages(chatId); const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id); const existing = existingIndex >= 0 ? list[existingIndex] : null; if (deleted) { if (existingIndex >= 0) { list.splice(existingIndex, 1); removeStoredMessageRecord(id); sortChatMessagesInPlace(chatId); 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 = Number(revisionTimeMs || 0); row.attachments = Array.isArray(attachments) ? attachments : []; row.unread = row.from === 'in' ? Boolean(unread) : false; row.refBaseKey = String(refBaseKey || ''); row.firstTick = row.from === 'out'; row.secondTick = Boolean(existing?.secondTick); row.readReceiptSent = Boolean(existing?.readReceiptSent); if (existingIndex < 0) { list.push(row); } sortChatMessagesInPlace(chatId); persistMessageRecord(chatId, row); return true; } export function markChatRead(chatId) { const list = getChatMessages(chatId); list.forEach((row) => { if (row?.from === 'in') { row.unread = false; persistMessageRecord(chatId, row); } }); } 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 async function saveEntrySettings(nextSettings) { const nextSolanaServer = String(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); 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, } = {}) { state.session.isAuthorized = true; state.session.login = login; state.session.sessionId = sessionId; state.session.storagePwdInMemory = storagePwd; persistSession({ isAuthorized: true, login, sessionId, }); state.startHint = ''; if (onSessionAuthorized) { onSessionAuthorized(); } } 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; } 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.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 || !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 } = {}) { if (closeServerSession) { await tryCloseCurrentSessionOnServer(); } clearStoredSession(); resetStateForSignedOut(); await clearStoredMessages().catch(() => {}); authService.close(); if (infoMessage) { state.startHint = infoMessage; } try { await authService.reconnect(state.entrySettings.shineServer); } catch { // ignore reconnect errors on sign out } if (onSessionReset) { onSessionReset(); } } export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) { await terminateCurrentSession({ infoMessage, closeServerSession: true }); } 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); }