Files

1454 lines
50 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';
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);
}