Files
SHiNE-server/shine-UI/js/state.js
T

905 lines
28 KiB
JavaScript

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 || ''),
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 || ''),
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,
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.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);
}