Промежуточный комит версии в которой ну хоть какието тестовые уведомления приходят. Но пока ещё вебпуш не работает
455 lines
13 KiB
JavaScript
455 lines
13 KiB
JavaScript
import { chatMessages, wallet } from './mock-data.js';
|
|
import { AuthService } from './services/auth-service.js';
|
|
import { clearClientAuthData } from './services/key-vault.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 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_SHINE_SERVER = 'wss://shineup.me/ws';
|
|
|
|
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 createInitialState({ withStoredSession = true } = {}) {
|
|
const storedSession = withStoredSession ? loadStoredSession() : null;
|
|
const storedReactions = loadStoredReactions();
|
|
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
|
|
|
return {
|
|
chats: clone(chatMessages),
|
|
contacts: [],
|
|
appLog: [],
|
|
incomingDedup: {},
|
|
notificationsTab: 'replies',
|
|
pageLabelCollapsed: false,
|
|
session: {
|
|
isAuthorized: false,
|
|
login: storedSession?.login || '',
|
|
sessionId: storedSession?.sessionId || '',
|
|
storagePwdInMemory: '',
|
|
},
|
|
startHint: '',
|
|
entrySettings: {
|
|
language: 'ru',
|
|
solanaServer: 'https://api.mainnet-beta.solana.com',
|
|
shineServer: initialShineServer,
|
|
arweaveServer: 'https://arweave.net',
|
|
statuses: {
|
|
solanaServer: 'idle',
|
|
shineServer: 'idle',
|
|
arweaveServer: 'idle',
|
|
},
|
|
},
|
|
registrationDraft: {
|
|
flowType: '',
|
|
login: '',
|
|
password: '',
|
|
sessionId: '',
|
|
storagePwd: '',
|
|
pendingKeyBundle: null,
|
|
pendingSessionMaterial: null,
|
|
},
|
|
loginDraft: {
|
|
login: storedSession?.login || '',
|
|
password: '',
|
|
},
|
|
registrationPayment: {
|
|
walletAddress: wallet.publicAddress,
|
|
balanceSOL: '0.0068',
|
|
},
|
|
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: '',
|
|
},
|
|
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;
|
|
|
|
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 });
|
|
}
|
|
|
|
|
|
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 });
|
|
return true;
|
|
}
|
|
|
|
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 = address.trim().toLowerCase();
|
|
if (!normalized) return 'unavailable';
|
|
|
|
const looksLikeUrl = /^(https?:\/\/|wss?:\/\/)[a-z0-9.-]+/i.test(normalized);
|
|
const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized);
|
|
return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
|
|
}
|
|
|
|
export async function saveEntrySettings(nextSettings) {
|
|
const forcedShineServer = LOCAL_WS_OVERRIDE_URL || nextSettings.shineServer;
|
|
state.entrySettings = {
|
|
...state.entrySettings,
|
|
...nextSettings,
|
|
shineServer: forcedShineServer,
|
|
statuses: {
|
|
...state.entrySettings.statuses,
|
|
...(nextSettings.statuses || {}),
|
|
},
|
|
};
|
|
await authService.reconnect(state.entrySettings.shineServer);
|
|
state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
|
|
}
|
|
|
|
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.notificationsTab = next.notificationsTab;
|
|
state.session = next.session;
|
|
state.startHint = next.startHint;
|
|
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;
|
|
}
|
|
|
|
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
|
clearStoredSession();
|
|
resetStateForSignedOut();
|
|
authService.close();
|
|
try {
|
|
await clearClientAuthData();
|
|
} catch {
|
|
// ignore cleanup errors in prototype mode
|
|
}
|
|
if (infoMessage) {
|
|
state.startHint = infoMessage;
|
|
}
|
|
if (onSessionReset) {
|
|
onSessionReset();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|