880 lines
27 KiB
JavaScript
880 lines
27 KiB
JavaScript
import { AuthService } from './services/auth-service.js';
|
||
import { listStoredMessages, putStoredMessage, 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 || ''),
|
||
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(() => {});
|
||
}
|
||
|
||
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 || ''),
|
||
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 = '',
|
||
} = {}) {
|
||
const id = String(messageKey || '').trim();
|
||
if (!chatId || !id) return false;
|
||
if (state.knownMessageKeys[id]) return false;
|
||
state.knownMessageKeys[id] = true;
|
||
|
||
const row = {
|
||
from: from === 'out' ? 'out' : 'in',
|
||
text: String(text || ''),
|
||
messageKey: id,
|
||
baseKey: String(baseKey || ''),
|
||
messageType: Number(messageType || 0),
|
||
rawBlobB64: String(rawBlobB64 || ''),
|
||
unread: Boolean(unread),
|
||
refBaseKey: String(refBaseKey || ''),
|
||
firstTick: from === 'out',
|
||
secondTick: false,
|
||
};
|
||
getChatMessages(chatId).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);
|
||
}
|