From ebc914359321a0149442f6bf71439a3bee681d03333ee931751d7af7c35e3d15 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 3 Sep 2026 12:37:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BC=D0=B5=D0=BD=D1=83=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=B5=D0=B9,=20=D0=BD=D0=BE=20=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Personal_Messages/Протокол_DM_v1.md | 7 + docs/Personal_Messages/Формат_DM_v1.md | 3 + shine-UI/firebase-messaging-sw.js | 15 +- shine-UI/js/app.js | 103 ++++++- shine-UI/js/pages/login-camera-view.js | 2 +- shine-UI/js/pages/login-other-device-view.js | 4 +- shine-UI/js/pages/profile-view.js | 1 + shine-UI/js/pages/profiles-view.js | 146 ++++++++++ shine-UI/js/pages/registration-keys-view.js | 2 +- .../js/pages/registration-payment-view.js | 2 +- shine-UI/js/pages/settings-view.js | 9 +- shine-UI/js/router.js | 6 + shine-UI/js/services/message-store.js | 113 ++++++-- shine-UI/js/state.js | 257 +++++++++++++++++- shine-UI/styles/components.css | 46 ++++ 15 files changed, 672 insertions(+), 44 deletions(-) create mode 100644 shine-UI/js/pages/profiles-view.js diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 1b657b29..e58e29d5 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -204,6 +204,13 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает - Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются. - Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени. +## Мультипрофильный клиент (UI, 2026-09-03) +- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM. +- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна. +- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие. +- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным. +- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались. + ## UI: видимость пустого диалога после DeleteConversation `DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 778ceb1f..a5842884 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -357,6 +357,9 @@ ReadReceiptBody_v1_0 ## Примечание UI списка чатов (2026-08-28) Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет. +## Примечание о мультипрофиле (2026-09-03) +Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`). + ## UI-семантика `type=7/8` в списке диалогов Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог. diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js index a07c9467..855678ee 100644 --- a/shine-UI/firebase-messaging-sw.js +++ b/shine-UI/firebase-messaging-sw.js @@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => { } const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const existing = allClients.find((client) => { - try { - return client.url.includes('/index.html') || client.url.endsWith('/'); - } catch { - return false; - } - }); + const existing = allClients[0] || null; const openUrlBase = './index.html'; const encodedPayload = encodeCallPushPayloadForUrl(payload); const openUrl = (action === 'accept' || action === 'decline') ? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}` - : openUrlBase; + : `${openUrlBase}?pushOpenPayload=${encodedPayload}`; if (existing) { try { @@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => { action, payload, }); + } else { + existing.postMessage({ + type: 'SHINE_NOTIFICATION_CLICK', + payload, + }); } } catch {} await existing.focus(); diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 428a1db5..b882a32d 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -33,6 +33,9 @@ import { addAppLogEntry, authorizeSession, hydrateMessagesFromStore, + getSavedProfiles, + closeSavedProfile, + switchToSavedProfile, isSessionInvalidError, refreshSessions, setSessionAuthorizedHandler, @@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js'; import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileEditView from './pages/profile-edit-view.js'; +import * as profilesView from './pages/profiles-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; import * as accessServersView from './pages/access-servers-view.js'; @@ -132,6 +136,7 @@ const routes = { queue: publicSupportQueueView, 'profile-view': profileView, 'profile-edit-view': profileEditView, + 'profiles-view': profilesView, 'wallet-view': walletView, 'settings-view': settingsView, 'access-servers-view': accessServersView, @@ -213,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([ const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([ ...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'), 'settings-view', + 'profiles-view', ]); const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([ @@ -745,6 +751,77 @@ function consumeCallPushActionFromUrlIfAny() { } } + +function pushTargetLogin(payload = {}) { + return String(payload?.toLogin || '').trim(); +} + +function pushTargetPath(payload = {}) { + const kind = String(payload?.kind || '').trim(); + const fromLogin = String(payload?.fromLogin || '').trim(); + if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`; + return '/profile'; +} + +function savedProfileExists(login) { + const normalized = String(login || '').trim().toLowerCase(); + if (!normalized) return false; + return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized); +} + +async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) { + const targetLogin = pushTargetLogin(payload); + const currentLogin = String(state.session.login || '').trim(); + if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true; + if (!savedProfileExists(targetLogin)) { + showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`); + return false; + } + + const kind = String(payload?.kind || '').trim(); + const question = kind === 'incoming_call' + ? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?` + : `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`; + if (!window.confirm(question)) return false; + + try { + await switchToSavedProfile(targetLogin); + if (action === 'accept' || action === 'decline') { + savePendingCallPushAction(action, payload); + window.location.assign(pushTargetPath(payload)); + } else { + window.location.assign(pushTargetPath(payload)); + } + return false; + } catch (error) { + showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`); + return false; + } +} + +async function handleNotificationClick(payload = {}) { + const canOpen = await ensurePushTargetProfile(payload); + if (!canOpen) return; + const path = pushTargetPath(payload); + navigate(path.replace(/^\//, '')); +} + +function consumeNotificationOpenFromUrlIfAny() { + try { + const params = new URLSearchParams(window.location.search || ''); + const rawPayload = String(params.get('pushOpenPayload') || ''); + if (!rawPayload) return null; + let payload = {}; + try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {} + params.delete('pushOpenPayload'); + const nextQuery = params.toString(); + window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`); + return payload; + } catch { + return null; + } +} + async function processPendingCallPushActionIfPossible() { if (!state.session.isAuthorized) return; const pending = loadPendingCallPushAction(); @@ -1286,6 +1363,11 @@ async function tryAutoLogin() { } catch {} } catch (error) { if (isSessionInvalidError(error)) { + const result = await closeSavedProfile(state.session.login); + if (result?.nextProfile) { + window.location.assign('/profile'); + return; + } await terminateCurrentSession({ infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', }); @@ -1338,6 +1420,7 @@ async function ensureSessionRuntimeStarted() { async function init() { consumeCallPushActionFromUrlIfAny(); + const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny(); void tryLockPortraitOrientation(); if (state.session.isLocalDemo) { @@ -1373,12 +1456,20 @@ async function init() { const action = String(data.action || '').trim().toLowerCase(); const payload = data.payload || {}; if (action === 'accept' || action === 'decline') { - if (!isCallPushTargetForCurrentSession(payload)) return; - savePendingCallPushAction(action, payload); - void processPendingCallPushActionIfPossible(); + void (async () => { + const canHandle = await ensurePushTargetProfile(payload, { action }); + if (!canHandle) return; + if (!isCallPushTargetForCurrentSession(payload)) return; + savePendingCallPushAction(action, payload); + await processPendingCallPushActionIfPossible(); + })(); } return; } + if (data.type === 'SHINE_NOTIFICATION_CLICK') { + void handleNotificationClick(data.payload || {}); + return; + } if (data.type !== 'SHINE_WEB_PUSH_EVENT') return; const payload = data.payload || {}; @@ -1411,7 +1502,8 @@ async function init() { } authService.onEvent('SessionRevoked', async () => { - await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' }); + const result = await closeSavedProfile(state.session.login); + window.location.assign(result?.nextProfile ? '/profile' : '/start'); }); authService.onEvent('ForceUiReload', async (evt) => { @@ -1714,6 +1806,9 @@ async function init() { void (async () => { try { await tryAutoLogin(); + if (initialNotificationOpenPayload) { + await handleNotificationClick(initialNotificationOpenPayload); + } await hydrateMessagesFromStore(); if (!state.session.isLocalDemo) { startConnectionMonitor(); diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index 53ecb9f0..1ba503c9 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -206,7 +206,7 @@ export function render({ navigate }) { await authService.reconnect(state.entrySettings.shineServer); const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys); await terminateCurrentSession({ closeServerSession: true }); - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(session.login).catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys); diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 2eb2a313..6886f4c7 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -183,7 +183,7 @@ export function render({ navigate }) { const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(session.login).catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); await saveEncryptedUserSecrets(session.login, session.storagePwd, keys); @@ -216,7 +216,7 @@ export function render({ navigate }) { }; await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(login).catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); await authService.persistSessionMaterial(login, sessionMaterial); diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 5aea0834..6302bc6e 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -91,6 +91,7 @@ export function render({ navigate, chrome }) { { label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') }, { label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') }, { label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') }, + { label: 'Сменить профиль', action: () => navigate('profiles-view') }, ], }); chrome?.setTopbar(topbar); diff --git a/shine-UI/js/pages/profiles-view.js b/shine-UI/js/pages/profiles-view.js new file mode 100644 index 00000000..5a4c0fc2 --- /dev/null +++ b/shine-UI/js/pages/profiles-view.js @@ -0,0 +1,146 @@ +import { renderHeader } from '../components/header.js'; +import { + closeAllSavedProfiles, + closeSavedProfile, + getSavedProfiles, + prepareAddProfileLogin, + state, + switchToSavedProfile, +} from '../state.js'; + +export const pageMeta = { id: 'profiles-view', title: 'Профили' }; + +function reloadTo(path) { + const clean = String(path || '/profile').trim() || '/profile'; + window.location.assign(clean.startsWith('/') ? clean : `/${clean}`); +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack profiles-screen'; + + screen.append(renderHeader({ + title: 'Профили', + leftAction: { label: '←', onClick: () => navigate('profile-view') }, + })); + + const intro = document.createElement('div'); + intro.className = 'meta-muted profiles-summary'; + + const list = document.createElement('div'); + list.className = 'stack profiles-list'; + + const status = document.createElement('div'); + status.className = 'status-line'; + status.hidden = true; + + const actions = document.createElement('div'); + actions.className = 'stack profiles-actions'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'secondary-btn'; + addButton.textContent = 'Добавить профиль'; + addButton.addEventListener('click', () => { + prepareAddProfileLogin(); + navigate('login-view'); + }); + + const closeAllButton = document.createElement('button'); + closeAllButton.type = 'button'; + closeAllButton.className = 'secondary-btn profiles-close-all'; + closeAllButton.textContent = 'Закрыть все профили'; + closeAllButton.addEventListener('click', async () => { + const profiles = getSavedProfiles(); + if (!profiles.length) return; + const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.'); + if (!confirmed) return; + closeAllButton.disabled = true; + status.hidden = false; + status.textContent = 'Закрываем профили…'; + try { + await closeAllSavedProfiles(); + reloadTo('/start'); + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось закрыть профили: ${error?.message || 'unknown'}`; + closeAllButton.disabled = false; + } + }); + + actions.append(addButton, closeAllButton); + screen.append(intro, list, status, actions); + + const renderList = () => { + const profiles = getSavedProfiles(); + const active = profiles.find((item) => item.isActive); + intro.textContent = profiles.length + ? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}` + : 'На устройстве нет сохранённых профилей.'; + closeAllButton.disabled = profiles.length === 0; + list.innerHTML = ''; + + profiles.forEach((profile) => { + const row = document.createElement('div'); + row.className = `card profiles-row${profile.isActive ? ' is-active' : ''}`; + + const select = document.createElement('button'); + select.type = 'button'; + select.className = 'profiles-select'; + select.innerHTML = `${profile.login}${profile.isActive ? 'Активный' : ''}`; + select.disabled = profile.isActive; + select.addEventListener('click', async () => { + if (profile.isActive) return; + const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`); + if (!confirmed) return; + status.hidden = false; + status.className = 'status-line'; + status.textContent = `Подключаем профиль ${profile.login}…`; + try { + await switchToSavedProfile(profile.login); + reloadTo('/profile'); + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось переключить профиль: ${error?.message || 'unknown'}`; + } + }); + + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'profiles-close'; + close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`); + close.textContent = '×'; + close.addEventListener('click', async () => { + const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase()); + const message = profile.isActive + ? (others.length + ? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.` + : `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`) + : `Закрыть профиль «${profile.login}» на этом устройстве?`; + if (!window.confirm(message)) return; + + status.hidden = false; + status.className = 'status-line'; + status.textContent = `Закрываем профиль ${profile.login}…`; + try { + const result = await closeSavedProfile(profile.login); + if (profile.isActive) { + reloadTo(result.nextProfile ? '/profile' : '/start'); + return; + } + status.hidden = true; + renderList(); + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось закрыть профиль: ${error?.message || 'unknown'}`; + } + }); + + row.append(select, close); + list.append(row); + }); + }; + + renderList(); + return screen; +} diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 70c21511..dbf2d1e7 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -143,7 +143,7 @@ export function render({ navigate }) { state.registrationDraft.pendingKeyBundle.blockchainPair = null; } - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(state.registrationDraft.login).catch(() => {}); authorizeSession({ login: state.registrationDraft.login, diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 29087031..d6b1e172 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) { }, ); await authService.persistSessionMaterial(result.login, result.sessionMaterial); - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(state.registrationDraft.login).catch(() => {}); const resumed = await authService.resumeSession(result.login, result.sessionId); const resumedLogin = resumed.login || result.login; diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 61c042c8..57604766 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -1,5 +1,5 @@ import { renderHeader } from '../components/header.js'; -import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js'; +import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js'; export const pageMeta = { id: 'settings-view', title: 'Настройки' }; @@ -77,7 +77,7 @@ export function render({ navigate }) { const signOutBtn = card.querySelector('#settings-signout'); signOutBtn.addEventListener('click', async () => { const confirmed = window.confirm( - 'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?' + 'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.' ); if (!confirmed) return; @@ -88,9 +88,8 @@ export function render({ navigate }) { source: 'session', message: 'Запрошено завершение текущей сессии', }); - await closeCurrentSessionAndSignOut({ - infoMessage: 'Сеанс завершён. Выполните вход заново.', - }); + const result = await closeSavedProfile(state.session.login); + window.location.assign(result?.nextProfile ? '/profile' : '/start'); } finally { signOutBtn.disabled = false; } diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 90bc794c..d4b87b0d 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -19,6 +19,7 @@ const PRETTY_PATHS = new Map([ ['key-storage-view', 'key-storage'], ['profile-view', 'profile'], ['profile-edit-view', 'profile/edit'], + ['profiles-view', 'profiles'], ['messages-list', 'messages'], ['contact-search-view', 'contacts'], ['chat-view', 'chat'], @@ -248,6 +249,10 @@ export function parseRouteFromPath(pathname = '') { return { pageId: 'profile-view', params: {} }; } + if (pageId === 'profiles') { + return { pageId: 'profiles-view', params: {} }; + } + if (pageId === 'messages') { return { pageId: 'messages-list', params: {} }; } @@ -437,6 +442,7 @@ export function resolveToolbarActive(pageId) { ) return pageId; if ( pageId === 'profile-edit-view' || + pageId === 'profiles-view' || pageId === 'wallet-view' || pageId === 'settings-view' || pageId === 'access-servers-view' || diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js index 2d5c6720..7c8c18b2 100644 --- a/shine-UI/js/services/message-store.js +++ b/shine-UI/js/services/message-store.js @@ -1,16 +1,67 @@ const DB_NAME = 'shine-ui-messages-v1'; -const DB_VERSION = 1; -const STORE_MESSAGES = 'messages'; +const DB_VERSION = 3; +const STORE_MESSAGES = 'messages_by_profile'; +const LEGACY_STORE_MESSAGES = 'messages'; +const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1'; +const LEGACY_SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; + +function normalizeOwnerLogin(value) { + return String(value || '').trim().toLowerCase(); +} + +function storageKey(ownerLogin, messageKey) { + const owner = normalizeOwnerLogin(ownerLogin); + const key = String(messageKey || '').trim(); + return owner && key ? `${owner}|${key}` : ''; +} + +function migrationOwnerLogin() { + try { + const active = normalizeOwnerLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY)); + if (active) return active; + const legacy = JSON.parse(localStorage.getItem(LEGACY_SESSION_STORAGE_KEY) || '{}'); + return normalizeOwnerLogin(legacy?.login); + } catch { + return ''; + } +} + +function ensureIndexes(store) { + if (!store.indexNames.contains('by_chat')) store.createIndex('by_chat', 'chatId', { unique: false }); + if (!store.indexNames.contains('by_ts')) store.createIndex('by_ts', 'ts', { unique: false }); + if (!store.indexNames.contains('by_owner')) store.createIndex('by_owner', 'ownerLogin', { unique: false }); +} function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; + let store; if (!db.objectStoreNames.contains(STORE_MESSAGES)) { - const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' }); - store.createIndex('by_chat', 'chatId', { unique: false }); - store.createIndex('by_ts', 'ts', { unique: false }); + store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'storageKey' }); + } else { + store = request.transaction.objectStore(STORE_MESSAGES); + } + ensureIndexes(store); + + // Однократная миграция старого single-profile кэша в пространство текущего профиля. + if (db.objectStoreNames.contains(LEGACY_STORE_MESSAGES)) { + const owner = migrationOwnerLogin(); + if (owner) { + const legacy = request.transaction.objectStore(LEGACY_STORE_MESSAGES); + const cursorReq = legacy.openCursor(); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) return; + const row = cursor.value || {}; + const messageKey = String(row.messageKey || '').trim(); + const rowOwner = normalizeOwnerLogin(row.ownerLogin) || owner; + const key = storageKey(rowOwner, messageKey); + if (key) store.put({ ...row, ownerLogin: rowOwner, storageKey: key }); + cursor.continue(); + }; + } } }; request.onsuccess = () => resolve(request.result); @@ -36,31 +87,55 @@ async function withStore(mode, callback) { export async function putStoredMessage(record) { if (!record || !record.messageKey) return; + const ownerLogin = normalizeOwnerLogin(record.ownerLogin); + const key = storageKey(ownerLogin, record.messageKey); + if (!key) return; await withStore('readwrite', (store) => { - store.put(record); + store.put({ ...record, ownerLogin, storageKey: key }); }); } -export async function deleteStoredMessage(messageKey) { - if (!messageKey) return; +export async function deleteStoredMessage(messageKey, ownerLogin = '') { + const key = storageKey(ownerLogin, messageKey); + if (!key) return; await withStore('readwrite', (store) => { - store.delete(messageKey); + store.delete(key); }); } -export async function listStoredMessages() { +export async function listStoredMessages(ownerLogin = '') { + const owner = normalizeOwnerLogin(ownerLogin); + if (!owner) return []; return withStore('readonly', (store) => new Promise((resolve, reject) => { - const req = store.getAll(); + const req = store.index('by_owner').getAll(owner); req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []); - req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed')); + req.onerror = () => reject(req.error || new Error('IndexedDB getAll by owner failed')); })); } -export async function clearStoredMessages() { - await new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(DB_NAME); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error || new Error('IndexedDB delete failed')); - request.onblocked = () => reject(new Error('IndexedDB delete blocked')); - }); +export async function clearStoredMessages(ownerLogin = '') { + const owner = normalizeOwnerLogin(ownerLogin); + if (!owner) { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error || new Error('IndexedDB delete failed')); + request.onblocked = () => reject(new Error('IndexedDB delete blocked')); + }); + return; + } + await withStore('readwrite', (store) => new Promise((resolve, reject) => { + const index = store.index('by_owner'); + const req = index.openKeyCursor(IDBKeyRange.only(owner)); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + resolve(); + return; + } + store.delete(cursor.primaryKey); + cursor.continue(); + }; + req.onerror = () => reject(req.error || new Error('IndexedDB clear by owner failed')); + })); } diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 661c7c0e..dbabd2af 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -11,6 +11,8 @@ 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'; @@ -122,7 +124,121 @@ function normalizeToolsSettings(rawTools) { }; } + +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; @@ -169,6 +285,15 @@ function clearStoredSession() { } 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; @@ -199,6 +324,19 @@ function persistEntrySettings(settings) { 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 } @@ -402,6 +540,7 @@ function persistMessageRecord(chatId, row) { 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 || ''), @@ -422,12 +561,12 @@ function persistMessageRecord(chatId, row) { function removeStoredMessageRecord(messageKey) { if (!messageKey) return; - void deleteStoredMessage(messageKey).catch(() => {}); + void deleteStoredMessage(messageKey, state.session.login).catch(() => {}); } export async function hydrateMessagesFromStore() { try { - const rows = await listStoredMessages(); + const rows = await listStoredMessages(state.session.login); const touchedChats = new Set(); rows .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) @@ -928,6 +1067,7 @@ export function authorizeSession({ login, sessionId, }); + upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings }); authService.setActiveSessionContext({ login, sessionId }); state.startHint = ''; if (onSessionAuthorized) { @@ -1020,13 +1160,14 @@ async function tryCloseCurrentSessionOnServer() { } export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) { + const signedOutLogin = String(state.session.login || '').trim(); if (closeServerSession) { await tryCloseCurrentSessionOnServer(); } clearStoredSession(); resetStateForSignedOut(); - await clearStoredMessages().catch(() => {}); + await clearStoredMessages(signedOutLogin).catch(() => {}); authService.close(); authService.clearActiveSessionContext(); if (infoMessage) { @@ -1046,6 +1187,116 @@ 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 targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); + const probe = new AuthService(targetServer); + try { + await probe.reconnect(targetServer); + const resumed = await probe.resumeSession(target.login, target.sessionId); + target.login = resumed.login || target.login; + target.sessionId = resumed.sessionId || target.sessionId; + target.updatedAtMs = Date.now(); + 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; + } finally { + probe.close(); + } +} + +async function closeSavedProfileSessionBestEffort(profile) { + if (!profile || profile.isLocalDemo) return; + const cleanSessionId = String(profile.sessionId || '').trim(); + if (!cleanSessionId) return; + const normalized = normalizeProfileLogin(profile.login); + if (normalized === normalizeProfileLogin(state.session.login) && state.session.isAuthorized) { + try { await authService.closeSession(cleanSessionId); } catch {} + return; + } + const targetServer = String(profile?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); + const probe = new AuthService(targetServer); + try { + await probe.reconnect(targetServer); + await probe.resumeSession(profile.login, cleanSessionId); + await probe.closeSession(cleanSessionId); + } catch { + // Закрытие профиля на устройстве не блокируем из-за недоступного сервера. + } finally { + probe.close(); + } +} + +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(); + 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 closeSavedProfileSessionBestEffort(item); + await clearStoredMessages(item.login).catch(() => {}); + } + persistProfileStoreRaw([]); + setActiveProfileLoginRaw(''); + clearStoredSession(); + authService.close(); + authService.clearActiveSessionContext(); +} + +export function prepareAddProfileLogin() { + state.loginDraft.login = ''; + state.loginDraft.password = ''; + state.authReturnHash = ''; +} + export function refreshRegistrationBalance() { const next = (0.005 + Math.random() * 0.03).toFixed(4); state.registrationPayment.balanceSOL = next; diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 43fd6e78..a40c1283 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -11676,3 +11676,49 @@ body.chat-topbar-overlay .page-header.app-topbar-shell .header-center { filter: blur(6px); transform: translate(-50%, -50%) scale(1); } + +/* Saved profiles */ +.profiles-screen { gap: 14px; } +.profiles-summary { padding: 0 2px; } +.profiles-list { gap: 10px; } +.profiles-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: center; + gap: 8px; + padding: 8px 8px 8px 14px; +} +.profiles-row.is-active { border-color: rgba(255, 255, 255, 0.34); } +.profiles-select { + min-width: 0; + border: 0; + background: transparent; + color: inherit; + display: flex; + align-items: center; + gap: 10px; + padding: 9px 0; + text-align: left; + font: inherit; +} +.profiles-select:not(:disabled) { cursor: pointer; } +.profiles-login { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; +} +.profiles-close { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.06); + color: inherit; + font-size: 27px; + line-height: 1; + cursor: pointer; +} +.profiles-actions { margin-top: 4px; } +.profiles-close-all { margin-top: 4px; }