UI: мультиаккаунты профиля и улучшенный поиск каналов
This commit is contained in:
parent
56a69ab683
commit
94263a46bd
@ -0,0 +1,32 @@
|
|||||||
|
# Мультиаккаунты + улучшенный поиск каналов/чатов
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- Добавлен long-press на кнопку `Профиль` в нижнем тулбаре.
|
||||||
|
- По удержанию открывается меню с кнопкой `Сменить профиль`.
|
||||||
|
- Добавлен экран `Сменить профиль`:
|
||||||
|
- список уже добавленных аккаунтов;
|
||||||
|
- пометка активного аккаунта;
|
||||||
|
- переключение на другой аккаунт;
|
||||||
|
- кнопки `Добавить аккаунт (Войти)` и `Добавить аккаунт (Регистрация)`.
|
||||||
|
- Сессии нескольких аккаунтов сохраняются локально; при `authorizeSession` аккаунт добавляется/обновляется в списке.
|
||||||
|
- Выход из текущей сессии теперь удаляет только текущий аккаунт из списка аккаунтов.
|
||||||
|
- В `Новый персональный публичный чат` разрешён логин длиной 1 символ (тип канала `100`).
|
||||||
|
- В `Найти канал` улучшен UX:
|
||||||
|
- кнопка `Найти`;
|
||||||
|
- поиск пользователей по началу логина;
|
||||||
|
- понятные сообщения при отсутствии совпадений/каналов.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- Удержать кнопку `Профиль` и открыть `Сменить профиль`.
|
||||||
|
- Проверить отображение активного аккаунта и переключение на другой.
|
||||||
|
- Проверить сценарий `Добавить аккаунт` (войти/зарегистрироваться) без вылета из текущего аккаунта.
|
||||||
|
- Проверить создание персонального публичного чата с логином из 1 символа.
|
||||||
|
- Проверить поиск каналов по префиксу логина и работу кнопки `Найти`.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- Переключение между несколькими аккаунтами работает из UI.
|
||||||
|
- Поиск каналов стал управляемым и понятным.
|
||||||
|
- Ограничение 3+ символов для персонального чата снято.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.53
|
client.version=1.2.54
|
||||||
server.version=1.2.47
|
server.version=1.2.48
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
markIncomingReadByBaseKey,
|
markIncomingReadByBaseKey,
|
||||||
markOutgoingReadByBaseKey,
|
markOutgoingReadByBaseKey,
|
||||||
setContacts,
|
setContacts,
|
||||||
|
cancelAddAccountFlow,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
|
|
||||||
import * as startView from './pages/start-view.js';
|
import * as startView from './pages/start-view.js';
|
||||||
@ -44,6 +45,7 @@ import * as loginPasswordView from './pages/login-password-view.js';
|
|||||||
import * as keyStorageView from './pages/key-storage-view.js';
|
import * as keyStorageView from './pages/key-storage-view.js';
|
||||||
|
|
||||||
import * as profileView from './pages/profile-view.js';
|
import * as profileView from './pages/profile-view.js';
|
||||||
|
import * as accountSwitcherView from './pages/account-switcher-view.js';
|
||||||
import * as profileEditView from './pages/profile-edit-view.js';
|
import * as profileEditView from './pages/profile-edit-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js';
|
import * as walletView from './pages/wallet-view.js';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
@ -83,6 +85,7 @@ const routes = {
|
|||||||
'login-password-view': loginPasswordView,
|
'login-password-view': loginPasswordView,
|
||||||
'key-storage-view': keyStorageView,
|
'key-storage-view': keyStorageView,
|
||||||
'profile-view': profileView,
|
'profile-view': profileView,
|
||||||
|
'account-switcher-view': accountSwitcherView,
|
||||||
'profile-edit-view': profileEditView,
|
'profile-edit-view': profileEditView,
|
||||||
'wallet-view': walletView,
|
'wallet-view': walletView,
|
||||||
'settings-view': settingsView,
|
'settings-view': settingsView,
|
||||||
@ -676,10 +679,13 @@ function renderApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !state.accountAddingMode) {
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && state.accountAddingMode) {
|
||||||
|
cancelAddAccountFlow();
|
||||||
|
}
|
||||||
|
|
||||||
const page = routes[pageId] || routes['start-view'];
|
const page = routes[pageId] || routes['start-view'];
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const ITEMS = [
|
|||||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
|
||||||
];
|
];
|
||||||
const CHANNEL_HOLD_MS = 260;
|
const CHANNEL_HOLD_MS = 260;
|
||||||
|
const PROFILE_HOLD_MS = 320;
|
||||||
const CHANNEL_MODES = Object.freeze([
|
const CHANNEL_MODES = Object.freeze([
|
||||||
{ key: 'feed', label: 'Каналы' },
|
{ key: 'feed', label: 'Каналы' },
|
||||||
{ key: 'dialogs', label: 'Чаты' },
|
{ key: 'dialogs', label: 'Чаты' },
|
||||||
@ -62,6 +63,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
}
|
}
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
installChannelsHoldSwitcher(btn, navigate);
|
installChannelsHoldSwitcher(btn, navigate);
|
||||||
|
} else if (item.pageId === 'profile-view') {
|
||||||
|
installProfileHoldMenu(btn, navigate);
|
||||||
} else {
|
} else {
|
||||||
btn.addEventListener('click', () => navigate(item.pageId));
|
btn.addEventListener('click', () => navigate(item.pageId));
|
||||||
}
|
}
|
||||||
@ -71,6 +74,73 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installProfileHoldMenu(button, navigate) {
|
||||||
|
let holdTimer = 0;
|
||||||
|
let pressed = false;
|
||||||
|
let holdActive = false;
|
||||||
|
let overlay = null;
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (holdTimer) {
|
||||||
|
window.clearTimeout(holdTimer);
|
||||||
|
holdTimer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeOverlay = () => {
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
overlay = null;
|
||||||
|
holdActive = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOverlay = () => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'toolbar-channels-hold-overlay';
|
||||||
|
overlay.style.minWidth = '190px';
|
||||||
|
overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`;
|
||||||
|
overlay.style.top = `${Math.round(rect.top - 12)}px`;
|
||||||
|
overlay.innerHTML = `<button type="button" class="toolbar-channels-hold-item is-active" data-action="switch-profile">Сменить профиль</button>`;
|
||||||
|
overlay.querySelector('[data-action="switch-profile"]')?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
closeOverlay();
|
||||||
|
navigate('account-switcher-view');
|
||||||
|
});
|
||||||
|
document.body.append(overlay);
|
||||||
|
holdActive = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
button.addEventListener('pointerdown', () => {
|
||||||
|
pressed = true;
|
||||||
|
holdActive = false;
|
||||||
|
clearTimer();
|
||||||
|
holdTimer = window.setTimeout(() => {
|
||||||
|
if (!pressed) return;
|
||||||
|
openOverlay();
|
||||||
|
}, PROFILE_HOLD_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
button.addEventListener('pointerup', () => {
|
||||||
|
clearTimer();
|
||||||
|
const wasHold = holdActive;
|
||||||
|
pressed = false;
|
||||||
|
if (wasHold) {
|
||||||
|
window.setTimeout(closeOverlay, 80);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('profile-view');
|
||||||
|
});
|
||||||
|
|
||||||
|
button.addEventListener('pointercancel', () => {
|
||||||
|
clearTimer();
|
||||||
|
pressed = false;
|
||||||
|
closeOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
button.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
function installChannelsHoldSwitcher(button, navigate) {
|
function installChannelsHoldSwitcher(button, navigate) {
|
||||||
let holdTimer = 0;
|
let holdTimer = 0;
|
||||||
let pressed = false;
|
let pressed = false;
|
||||||
|
|||||||
76
shine-UI/js/pages/account-switcher-view.js
Normal file
76
shine-UI/js/pages/account-switcher-view.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { beginAddAccountFlow, state, switchToAccount } from '../state.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'account-switcher-view', title: 'Сменить профиль' };
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: 'Сменить профиль',
|
||||||
|
leftAction: { label: '<', onClick: () => navigate('profile-view') },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'stack';
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'meta-muted';
|
||||||
|
|
||||||
|
const accounts = Array.isArray(state.accounts) ? state.accounts : [];
|
||||||
|
const activeLogin = String(state.session.login || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!accounts.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'card meta-muted';
|
||||||
|
empty.textContent = 'Сохранённых аккаунтов пока нет.';
|
||||||
|
list.append(empty);
|
||||||
|
} else {
|
||||||
|
accounts.forEach((account) => {
|
||||||
|
const login = String(account?.login || '').trim();
|
||||||
|
if (!login) return;
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.type = 'button';
|
||||||
|
card.className = 'card row';
|
||||||
|
card.style.justifyContent = 'space-between';
|
||||||
|
card.style.alignItems = 'center';
|
||||||
|
card.innerHTML = `
|
||||||
|
<strong>${login}</strong>
|
||||||
|
<span class="meta-muted">${login.toLowerCase() === activeLogin ? 'Активный' : 'Переключить'}</span>
|
||||||
|
`;
|
||||||
|
card.addEventListener('click', async () => {
|
||||||
|
if (login.toLowerCase() === activeLogin) return;
|
||||||
|
status.textContent = 'Переключаем аккаунт...';
|
||||||
|
try {
|
||||||
|
await switchToAccount(login);
|
||||||
|
status.textContent = '';
|
||||||
|
navigate('profile-view');
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = toUserMessage(error, 'Не удалось переключить аккаунт.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'form-actions-grid';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="secondary-btn" id="account-switcher-add-login" type="button">Добавить аккаунт (Войти)</button>
|
||||||
|
<button class="secondary-btn" id="account-switcher-add-register" type="button">Добавить аккаунт (Регистрация)</button>
|
||||||
|
`;
|
||||||
|
actions.querySelector('#account-switcher-add-login')?.addEventListener('click', () => {
|
||||||
|
beginAddAccountFlow();
|
||||||
|
navigate('login-view');
|
||||||
|
});
|
||||||
|
actions.querySelector('#account-switcher-add-register')?.addEventListener('click', () => {
|
||||||
|
beginAddAccountFlow();
|
||||||
|
navigate('register-view');
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.append(list, actions, status);
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
@ -407,12 +407,13 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
<div class="modal" id="channels-find-modal">
|
<div class="modal" id="channels-find-modal">
|
||||||
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||||
<h3 class="modal-title">Поиск каналов</h3>
|
<h3 class="modal-title">Поиск каналов</h3>
|
||||||
<p class="meta-muted">Введите логин или формат login/channel</p>
|
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
||||||
<input id="channels-find-input" class="input" placeholder="login/channel" autocomplete="off" />
|
<input id="channels-find-input" class="input" placeholder="Например: aid" autocomplete="off" />
|
||||||
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
|
<button class="primary-btn" id="channels-find-run" type="button">Найти</button>
|
||||||
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
|
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -496,9 +497,16 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
channelName: item.channelName,
|
channelName: item.channelName,
|
||||||
}));
|
}));
|
||||||
renderChannelRows(channels);
|
renderChannelRows(channels);
|
||||||
|
if (!channels.length) {
|
||||||
|
errorEl.textContent = filterChannel
|
||||||
|
? 'Каналы с таким фильтром не найдены.'
|
||||||
|
: `У пользователя "${ownerLogin}" пока нет доступных каналов.`;
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = createDebounced(async () => {
|
const runSearch = async () => {
|
||||||
const raw = String(inputEl?.value || '').trim();
|
const raw = String(inputEl?.value || '').trim();
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@ -506,6 +514,7 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
suggestEl.innerHTML = '';
|
suggestEl.innerHTML = '';
|
||||||
channelsEl.style.display = 'none';
|
channelsEl.style.display = 'none';
|
||||||
channelsEl.innerHTML = '';
|
channelsEl.innerHTML = '';
|
||||||
|
errorEl.textContent = 'Введите логин или начало логина.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,7 +530,7 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginPrefix.length < 2) {
|
if (loginPrefix.length < 1) {
|
||||||
suggestEl.style.display = 'none';
|
suggestEl.style.display = 'none';
|
||||||
suggestEl.innerHTML = '';
|
suggestEl.innerHTML = '';
|
||||||
channelsEl.style.display = 'none';
|
channelsEl.style.display = 'none';
|
||||||
@ -530,10 +539,20 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logins = await authService.searchUsers(loginPrefix);
|
const logins = await authService.searchUsers(loginPrefix);
|
||||||
|
const rows = Array.isArray(logins) ? logins : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
suggestEl.style.display = 'none';
|
||||||
|
suggestEl.innerHTML = '';
|
||||||
|
channelsEl.style.display = 'none';
|
||||||
|
channelsEl.innerHTML = '';
|
||||||
|
errorEl.textContent = `Логины, начинающиеся на "${loginPrefix}", не найдены.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
|
const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
|
||||||
label: login,
|
label: login,
|
||||||
login,
|
login,
|
||||||
}));
|
}));
|
||||||
|
errorEl.textContent = '';
|
||||||
renderButtons(suggestEl, items, async (item) => {
|
renderButtons(suggestEl, items, async (item) => {
|
||||||
inputEl.value = `${item.login}/`;
|
inputEl.value = `${item.login}/`;
|
||||||
suggestEl.style.display = 'none';
|
suggestEl.style.display = 'none';
|
||||||
@ -543,9 +562,19 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
|
||||||
}
|
}
|
||||||
}, 220);
|
};
|
||||||
|
|
||||||
|
const refresh = createDebounced(runSearch, 220);
|
||||||
|
|
||||||
root.querySelector('#channels-find-close')?.addEventListener('click', close);
|
root.querySelector('#channels-find-close')?.addEventListener('click', close);
|
||||||
|
root.querySelector('#channels-find-run')?.addEventListener('click', () => {
|
||||||
|
void runSearch();
|
||||||
|
});
|
||||||
|
inputEl?.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
event.preventDefault();
|
||||||
|
void runSearch();
|
||||||
|
});
|
||||||
inputEl?.addEventListener('input', refresh);
|
inputEl?.addEventListener('input', refresh);
|
||||||
if (inputEl) inputEl.focus();
|
if (inputEl) inputEl.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
if (ROOT_PAGES.includes(pageId)) return pageId;
|
if (ROOT_PAGES.includes(pageId)) return pageId;
|
||||||
if (
|
if (
|
||||||
pageId === 'profile-edit-view' ||
|
pageId === 'profile-edit-view' ||
|
||||||
|
pageId === 'account-switcher-view' ||
|
||||||
pageId === 'wallet-view' ||
|
pageId === 'wallet-view' ||
|
||||||
pageId === 'settings-view' ||
|
pageId === 'settings-view' ||
|
||||||
pageId === 'developer-settings-view' ||
|
pageId === 'developer-settings-view' ||
|
||||||
|
|||||||
@ -409,6 +409,19 @@ function normalizeChannelDescription(value) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePersonalChannelName(value) {
|
||||||
|
const normalized = normalizeChannelDisplayName(value);
|
||||||
|
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||||||
|
const length = Array.from(normalized).length;
|
||||||
|
if (length < 1 || length > 20) {
|
||||||
|
return { ok: false, error: 'Логин: 1-20 символов.' };
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9_]+$/.test(normalized)) {
|
||||||
|
return { ok: false, error: 'Логин: разрешены только латиница, цифры и _.' };
|
||||||
|
}
|
||||||
|
return { ok: true, normalized };
|
||||||
|
}
|
||||||
|
|
||||||
function makeCreateChannelBodyBytes({
|
function makeCreateChannelBodyBytes({
|
||||||
lineCode,
|
lineCode,
|
||||||
prevLineNumber,
|
prevLineNumber,
|
||||||
@ -419,9 +432,14 @@ function makeCreateChannelBodyBytes({
|
|||||||
channelType = CHANNEL_TYPE_PUBLIC,
|
channelType = CHANNEL_TYPE_PUBLIC,
|
||||||
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
||||||
}) {
|
}) {
|
||||||
const check = validateChannelDisplayName(channelName);
|
const typeCode = Number(channelType);
|
||||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
||||||
const cleanName = check.normalized;
|
? validatePersonalChannelName(channelName)
|
||||||
|
: validateChannelDisplayName(channelName);
|
||||||
|
if (!nameCheck.ok) {
|
||||||
|
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||||||
|
}
|
||||||
|
const cleanName = nameCheck.normalized;
|
||||||
const cleanDescription = normalizeChannelDescription(channelDescription);
|
const cleanDescription = normalizeChannelDescription(channelDescription);
|
||||||
|
|
||||||
const nameBytes = utf8Bytes(cleanName);
|
const nameBytes = utf8Bytes(cleanName);
|
||||||
@ -433,7 +451,6 @@ function makeCreateChannelBodyBytes({
|
|||||||
if (descriptionBytes.length > 200) {
|
if (descriptionBytes.length > 200) {
|
||||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||||
}
|
}
|
||||||
const typeCode = Number(channelType);
|
|
||||||
const typeVer = Number(channelTypeVersion);
|
const typeVer = Number(channelTypeVersion);
|
||||||
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
||||||
throw new Error('Некорректный тип канала.');
|
throw new Error('Некорректный тип канала.');
|
||||||
@ -463,8 +480,8 @@ function makeCreateChannelBodyBytesLegacy({
|
|||||||
thisLineNumber,
|
thisLineNumber,
|
||||||
channelName,
|
channelName,
|
||||||
}) {
|
}) {
|
||||||
const check = validateChannelDisplayName(channelName);
|
const check = validatePersonalChannelName(channelName);
|
||||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
if (!check.ok) throw new Error(check.error);
|
||||||
const cleanName = check.normalized;
|
const cleanName = check.normalized;
|
||||||
const nameBytes = utf8Bytes(cleanName);
|
const nameBytes = utf8Bytes(cleanName);
|
||||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||||
@ -1063,13 +1080,16 @@ export class AuthService {
|
|||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login');
|
if (!cleanLogin) throw new Error('Missing login');
|
||||||
|
|
||||||
const check = validateChannelDisplayName(channelName);
|
const typeCode = Number(channelType);
|
||||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
||||||
const cleanChannelName = normalizeChannelDisplayName(check.normalized);
|
? validatePersonalChannelName(channelName)
|
||||||
|
: validateChannelDisplayName(channelName);
|
||||||
|
if (!nameCheck.ok) {
|
||||||
|
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||||||
|
}
|
||||||
|
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
|
||||||
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
|
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
|
||||||
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||||||
|
|
||||||
const typeCode = Number(channelType);
|
|
||||||
const typeVersion = Number(channelTypeVersion);
|
const typeVersion = Number(channelTypeVersion);
|
||||||
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
||||||
return this.runWriteLocked(key, async () => {
|
return this.runWriteLocked(key, async () => {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { AuthService } from './services/auth-service.js';
|
import { AuthService } from './services/auth-service.js';
|
||||||
import { clearClientAuthData } from './services/key-vault.js';
|
import { listStoredMessages, putStoredMessage } from './services/message-store.js';
|
||||||
import { clearStoredMessages, listStoredMessages, putStoredMessage } from './services/message-store.js';
|
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
const ACCOUNTS_STORAGE_KEY = 'shine-ui-accounts-v1';
|
||||||
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
||||||
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
||||||
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
||||||
@ -117,6 +117,33 @@ function loadStoredSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadStoredAccounts() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ACCOUNTS_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.map((item) => ({
|
||||||
|
login: String(item?.login || '').trim(),
|
||||||
|
sessionId: String(item?.sessionId || '').trim(),
|
||||||
|
updatedAtMs: Number(item?.updatedAtMs || Date.now()),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.login && item.sessionId);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStoredAccounts(accounts) {
|
||||||
|
try {
|
||||||
|
const payload = Array.isArray(accounts) ? accounts : [];
|
||||||
|
localStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadStoredReactions() {
|
function loadStoredReactions() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(REACTIONS_STORAGE_KEY);
|
const raw = localStorage.getItem(REACTIONS_STORAGE_KEY);
|
||||||
@ -189,6 +216,7 @@ function persistEntrySettings(settings) {
|
|||||||
function clearBrowserClientData() {
|
function clearBrowserClientData() {
|
||||||
const localKeys = [
|
const localKeys = [
|
||||||
SESSION_STORAGE_KEY,
|
SESSION_STORAGE_KEY,
|
||||||
|
ACCOUNTS_STORAGE_KEY,
|
||||||
REACTIONS_STORAGE_KEY,
|
REACTIONS_STORAGE_KEY,
|
||||||
WEB_PUSH_SUBSCRIPTION_KEY,
|
WEB_PUSH_SUBSCRIPTION_KEY,
|
||||||
CHANNEL_NOTIFY_KEY,
|
CHANNEL_NOTIFY_KEY,
|
||||||
@ -211,6 +239,7 @@ function clearBrowserClientData() {
|
|||||||
|
|
||||||
function createInitialState({ withStoredSession = true } = {}) {
|
function createInitialState({ withStoredSession = true } = {}) {
|
||||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||||
|
const storedAccounts = loadStoredAccounts();
|
||||||
const storedReactions = loadStoredReactions();
|
const storedReactions = loadStoredReactions();
|
||||||
const storedEntrySettings = loadStoredEntrySettings();
|
const storedEntrySettings = loadStoredEntrySettings();
|
||||||
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
||||||
@ -230,6 +259,9 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
sessionId: storedSession?.sessionId || '',
|
sessionId: storedSession?.sessionId || '',
|
||||||
storagePwdInMemory: '',
|
storagePwdInMemory: '',
|
||||||
},
|
},
|
||||||
|
accounts: storedAccounts,
|
||||||
|
activeAccountLogin: String(storedSession?.login || ''),
|
||||||
|
accountAddingMode: false,
|
||||||
startHint: '',
|
startHint: '',
|
||||||
entrySettings: {
|
entrySettings: {
|
||||||
language: String(storedEntrySettings?.language || 'ru'),
|
language: String(storedEntrySettings?.language || 'ru'),
|
||||||
@ -699,6 +731,19 @@ export function authorizeSession({
|
|||||||
login,
|
login,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
const loginKey = String(login || '').trim().toLowerCase();
|
||||||
|
const nextAccounts = [
|
||||||
|
{
|
||||||
|
login: String(login || '').trim(),
|
||||||
|
sessionId: String(sessionId || '').trim(),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
},
|
||||||
|
...state.accounts.filter((item) => String(item?.login || '').trim().toLowerCase() !== loginKey),
|
||||||
|
];
|
||||||
|
state.accounts = nextAccounts;
|
||||||
|
state.activeAccountLogin = String(login || '').trim();
|
||||||
|
state.accountAddingMode = false;
|
||||||
|
persistStoredAccounts(nextAccounts);
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
if (onSessionAuthorized) {
|
if (onSessionAuthorized) {
|
||||||
onSessionAuthorized();
|
onSessionAuthorized();
|
||||||
@ -722,6 +767,20 @@ export async function refreshSessions() {
|
|||||||
return state.sessions;
|
return state.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function switchToAccount(login) {
|
||||||
|
const targetLogin = String(login || '').trim();
|
||||||
|
if (!targetLogin) throw new Error('Не передан логин аккаунта.');
|
||||||
|
const account = (state.accounts || []).find((item) => String(item?.login || '').trim().toLowerCase() === targetLogin.toLowerCase());
|
||||||
|
if (!account?.sessionId) throw new Error('Сессия аккаунта не найдена.');
|
||||||
|
const resumed = await authService.resumeSession(account.login, account.sessionId);
|
||||||
|
authorizeSession({
|
||||||
|
login: resumed.login || account.login,
|
||||||
|
sessionId: resumed.sessionId || account.sessionId,
|
||||||
|
storagePwd: resumed.storagePwd || state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
return resumed;
|
||||||
|
}
|
||||||
|
|
||||||
function resetStateForSignedOut() {
|
function resetStateForSignedOut() {
|
||||||
const next = createInitialState({ withStoredSession: false });
|
const next = createInitialState({ withStoredSession: false });
|
||||||
state.chats = next.chats;
|
state.chats = next.chats;
|
||||||
@ -733,6 +792,9 @@ function resetStateForSignedOut() {
|
|||||||
state.notificationsTab = next.notificationsTab;
|
state.notificationsTab = next.notificationsTab;
|
||||||
state.pageLabelCollapsed = next.pageLabelCollapsed;
|
state.pageLabelCollapsed = next.pageLabelCollapsed;
|
||||||
state.session = next.session;
|
state.session = next.session;
|
||||||
|
state.accounts = next.accounts;
|
||||||
|
state.activeAccountLogin = next.activeAccountLogin;
|
||||||
|
state.accountAddingMode = next.accountAddingMode;
|
||||||
state.startHint = next.startHint;
|
state.startHint = next.startHint;
|
||||||
state.entrySettings = next.entrySettings;
|
state.entrySettings = next.entrySettings;
|
||||||
state.registrationDraft = next.registrationDraft;
|
state.registrationDraft = next.registrationDraft;
|
||||||
@ -749,18 +811,13 @@ function resetStateForSignedOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
||||||
|
const currentLogin = String(state.session.login || '').trim().toLowerCase();
|
||||||
|
const nextAccounts = (state.accounts || []).filter((item) => String(item?.login || '').trim().toLowerCase() !== currentLogin);
|
||||||
|
state.accounts = nextAccounts;
|
||||||
|
persistStoredAccounts(nextAccounts);
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
clearBrowserClientData();
|
|
||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
authService.close();
|
authService.close();
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
clearClientAuthData(),
|
|
||||||
clearStoredMessages(),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors in prototype mode
|
|
||||||
}
|
|
||||||
if (infoMessage) {
|
if (infoMessage) {
|
||||||
state.startHint = infoMessage;
|
state.startHint = infoMessage;
|
||||||
}
|
}
|
||||||
@ -792,6 +849,14 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
|||||||
await terminateCurrentSession({ infoMessage });
|
await terminateCurrentSession({ infoMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function beginAddAccountFlow() {
|
||||||
|
state.accountAddingMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAddAccountFlow() {
|
||||||
|
state.accountAddingMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshRegistrationBalance() {
|
export function refreshRegistrationBalance() {
|
||||||
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
||||||
state.registrationPayment.balanceSOL = next;
|
state.registrationPayment.balanceSOL = next;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user