SHA256
Добавить смену профилей, но она пока не работает
This commit is contained in:
@@ -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 и сам по себе не считается пользовательским сообщением диалога.
|
||||
|
||||
@@ -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` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||
|
||||
@@ -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();
|
||||
|
||||
+99
-4
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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'));
|
||||
}));
|
||||
}
|
||||
|
||||
+254
-3
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user