diff --git a/Dev_Docs/Pending_Features/2026-05-14_1627_мультиаккаунты-и-улучшенный-поиск-каналов.md b/Dev_Docs/Pending_Features/2026-05-14_1627_мультиаккаунты-и-улучшенный-поиск-каналов.md
new file mode 100644
index 0000000..bf2a1dd
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-05-14_1627_мультиаккаунты-и-улучшенный-поиск-каналов.md
@@ -0,0 +1,32 @@
+# Мультиаккаунты + улучшенный поиск каналов/чатов
+
+- краткое описание фичи:
+ - Добавлен long-press на кнопку `Профиль` в нижнем тулбаре.
+ - По удержанию открывается меню с кнопкой `Сменить профиль`.
+ - Добавлен экран `Сменить профиль`:
+ - список уже добавленных аккаунтов;
+ - пометка активного аккаунта;
+ - переключение на другой аккаунт;
+ - кнопки `Добавить аккаунт (Войти)` и `Добавить аккаунт (Регистрация)`.
+ - Сессии нескольких аккаунтов сохраняются локально; при `authorizeSession` аккаунт добавляется/обновляется в списке.
+ - Выход из текущей сессии теперь удаляет только текущий аккаунт из списка аккаунтов.
+ - В `Новый персональный публичный чат` разрешён логин длиной 1 символ (тип канала `100`).
+ - В `Найти канал` улучшен UX:
+ - кнопка `Найти`;
+ - поиск пользователей по началу логина;
+ - понятные сообщения при отсутствии совпадений/каналов.
+
+- что именно проверять:
+ - Удержать кнопку `Профиль` и открыть `Сменить профиль`.
+ - Проверить отображение активного аккаунта и переключение на другой.
+ - Проверить сценарий `Добавить аккаунт` (войти/зарегистрироваться) без вылета из текущего аккаунта.
+ - Проверить создание персонального публичного чата с логином из 1 символа.
+ - Проверить поиск каналов по префиксу логина и работу кнопки `Найти`.
+
+- ожидаемый результат:
+ - Переключение между несколькими аккаунтами работает из UI.
+ - Поиск каналов стал управляемым и понятным.
+ - Ограничение 3+ символов для персонального чата снято.
+
+- статус:
+ - pending
diff --git a/VERSION.properties b/VERSION.properties
index dcd79e5..da975c1 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.53
-server.version=1.2.47
+client.version=1.2.54
+server.version=1.2.48
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 225b149..618a081 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -30,6 +30,7 @@ import {
markIncomingReadByBaseKey,
markOutgoingReadByBaseKey,
setContacts,
+ cancelAddAccountFlow,
} from './state.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 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 walletView from './pages/wallet-view.js';
import * as settingsView from './pages/settings-view.js';
@@ -83,6 +85,7 @@ const routes = {
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
'profile-view': profileView,
+ 'account-switcher-view': accountSwitcherView,
'profile-edit-view': profileEditView,
'wallet-view': walletView,
'settings-view': settingsView,
@@ -676,10 +679,13 @@ function renderApp() {
return;
}
- if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
+ if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !state.accountAddingMode) {
navigate('messages-list');
return;
}
+ if (state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && state.accountAddingMode) {
+ cancelAddAccountFlow();
+ }
const page = routes[pageId] || routes['start-view'];
diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js
index 67ef9dd..f7e9512 100644
--- a/shine-UI/js/components/toolbar.js
+++ b/shine-UI/js/components/toolbar.js
@@ -9,6 +9,7 @@ const ITEMS = [
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
];
const CHANNEL_HOLD_MS = 260;
+const PROFILE_HOLD_MS = 320;
const CHANNEL_MODES = Object.freeze([
{ key: 'feed', label: 'Каналы' },
{ key: 'dialogs', label: 'Чаты' },
@@ -62,6 +63,8 @@ export function renderToolbar(currentPageId, navigate) {
}
if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate);
+ } else if (item.pageId === 'profile-view') {
+ installProfileHoldMenu(btn, navigate);
} else {
btn.addEventListener('click', () => navigate(item.pageId));
}
@@ -71,6 +74,73 @@ export function renderToolbar(currentPageId, navigate) {
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 = ``;
+ 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) {
let holdTimer = 0;
let pressed = false;
diff --git a/shine-UI/js/pages/account-switcher-view.js b/shine-UI/js/pages/account-switcher-view.js
new file mode 100644
index 0000000..1f3739c
--- /dev/null
+++ b/shine-UI/js/pages/account-switcher-view.js
@@ -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 = `
+ ${login}
+ ${login.toLowerCase() === activeLogin ? 'Активный' : 'Переключить'}
+ `;
+ 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 = `
+
+
+ `;
+ 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;
+}
diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js
index 3df2990..65fae53 100644
--- a/shine-UI/js/pages/channels-list.js
+++ b/shine-UI/js/pages/channels-list.js
@@ -407,12 +407,13 @@ function openChannelFinderModal({ navigate }) {
Поиск каналов
-
Введите логин или формат login/channel
-
+
Введите логин (или начало логина), затем выберите пользователя и канал.
+
+
@@ -496,9 +497,16 @@ function openChannelFinderModal({ navigate }) {
channelName: item.channelName,
}));
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();
errorEl.textContent = '';
if (!raw) {
@@ -506,6 +514,7 @@ function openChannelFinderModal({ navigate }) {
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
channelsEl.innerHTML = '';
+ errorEl.textContent = 'Введите логин или начало логина.';
return;
}
@@ -521,7 +530,7 @@ function openChannelFinderModal({ navigate }) {
return;
}
- if (loginPrefix.length < 2) {
+ if (loginPrefix.length < 1) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
@@ -530,10 +539,20 @@ function openChannelFinderModal({ navigate }) {
}
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) => ({
label: login,
login,
}));
+ errorEl.textContent = '';
renderButtons(suggestEl, items, async (item) => {
inputEl.value = `${item.login}/`;
suggestEl.style.display = 'none';
@@ -543,9 +562,19 @@ function openChannelFinderModal({ navigate }) {
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
}
- }, 220);
+ };
+
+ const refresh = createDebounced(runSearch, 220);
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);
if (inputEl) inputEl.focus();
}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 9378a0b..0024ee4 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -142,6 +142,7 @@ export function resolveToolbarActive(pageId) {
if (ROOT_PAGES.includes(pageId)) return pageId;
if (
pageId === 'profile-edit-view' ||
+ pageId === 'account-switcher-view' ||
pageId === 'wallet-view' ||
pageId === 'settings-view' ||
pageId === 'developer-settings-view' ||
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index ab92cbe..9d0103c 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -409,6 +409,19 @@ function normalizeChannelDescription(value) {
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({
lineCode,
prevLineNumber,
@@ -419,9 +432,14 @@ function makeCreateChannelBodyBytes({
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
}) {
- const check = validateChannelDisplayName(channelName);
- if (!check.ok) throw new Error(channelNameErrorText(check.code));
- const cleanName = check.normalized;
+ const typeCode = Number(channelType);
+ const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
+ ? 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 nameBytes = utf8Bytes(cleanName);
@@ -433,7 +451,6 @@ function makeCreateChannelBodyBytes({
if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
}
- const typeCode = Number(channelType);
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
throw new Error('Некорректный тип канала.');
@@ -463,8 +480,8 @@ function makeCreateChannelBodyBytesLegacy({
thisLineNumber,
channelName,
}) {
- const check = validateChannelDisplayName(channelName);
- if (!check.ok) throw new Error(channelNameErrorText(check.code));
+ const check = validatePersonalChannelName(channelName);
+ if (!check.ok) throw new Error(check.error);
const cleanName = check.normalized;
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
@@ -1063,13 +1080,16 @@ export class AuthService {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
- const check = validateChannelDisplayName(channelName);
- if (!check.ok) throw new Error(channelNameErrorText(check.code));
- const cleanChannelName = normalizeChannelDisplayName(check.normalized);
+ const typeCode = Number(channelType);
+ const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
+ ? 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 channelSlug = toCanonicalChannelSlug(cleanChannelName);
-
- const typeCode = Number(channelType);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index c792907..386567b 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -1,9 +1,9 @@
import { AuthService } from './services/auth-service.js';
-import { clearClientAuthData } from './services/key-vault.js';
-import { clearStoredMessages, listStoredMessages, putStoredMessage } from './services/message-store.js';
+import { listStoredMessages, putStoredMessage } from './services/message-store.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
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 WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-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() {
try {
const raw = localStorage.getItem(REACTIONS_STORAGE_KEY);
@@ -189,6 +216,7 @@ function persistEntrySettings(settings) {
function clearBrowserClientData() {
const localKeys = [
SESSION_STORAGE_KEY,
+ ACCOUNTS_STORAGE_KEY,
REACTIONS_STORAGE_KEY,
WEB_PUSH_SUBSCRIPTION_KEY,
CHANNEL_NOTIFY_KEY,
@@ -211,6 +239,7 @@ function clearBrowserClientData() {
function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null;
+ const storedAccounts = loadStoredAccounts();
const storedReactions = loadStoredReactions();
const storedEntrySettings = loadStoredEntrySettings();
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
@@ -230,6 +259,9 @@ function createInitialState({ withStoredSession = true } = {}) {
sessionId: storedSession?.sessionId || '',
storagePwdInMemory: '',
},
+ accounts: storedAccounts,
+ activeAccountLogin: String(storedSession?.login || ''),
+ accountAddingMode: false,
startHint: '',
entrySettings: {
language: String(storedEntrySettings?.language || 'ru'),
@@ -699,6 +731,19 @@ export function authorizeSession({
login,
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 = '';
if (onSessionAuthorized) {
onSessionAuthorized();
@@ -722,6 +767,20 @@ export async function refreshSessions() {
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() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
@@ -733,6 +792,9 @@ function resetStateForSignedOut() {
state.notificationsTab = next.notificationsTab;
state.pageLabelCollapsed = next.pageLabelCollapsed;
state.session = next.session;
+ state.accounts = next.accounts;
+ state.activeAccountLogin = next.activeAccountLogin;
+ state.accountAddingMode = next.accountAddingMode;
state.startHint = next.startHint;
state.entrySettings = next.entrySettings;
state.registrationDraft = next.registrationDraft;
@@ -749,18 +811,13 @@ function resetStateForSignedOut() {
}
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();
- clearBrowserClientData();
resetStateForSignedOut();
authService.close();
- try {
- await Promise.all([
- clearClientAuthData(),
- clearStoredMessages(),
- ]);
- } catch {
- // ignore cleanup errors in prototype mode
- }
if (infoMessage) {
state.startHint = infoMessage;
}
@@ -792,6 +849,14 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
await terminateCurrentSession({ infoMessage });
}
+export function beginAddAccountFlow() {
+ state.accountAddingMode = true;
+}
+
+export function cancelAddAccountFlow() {
+ state.accountAddingMode = false;
+}
+
export function refreshRegistrationBalance() {
const next = (0.005 + Math.random() * 0.03).toFixed(4);
state.registrationPayment.balanceSOL = next;