SHA256
Обновить UI и документы личных сообщений
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.7.4
|
client.version=1.7.5
|
||||||
server.version=1.6.2
|
server.version=1.6.2
|
||||||
|
|||||||
@@ -197,3 +197,9 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
|||||||
- read-receipts;
|
- read-receipts;
|
||||||
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
|
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
|
||||||
- общий постоянный WSS ServerConnectionPool.
|
- общий постоянный WSS ServerConnectionPool.
|
||||||
|
|
||||||
|
## UI списка личных чатов — фильтры и отображение времени (2026-08-28)
|
||||||
|
- Заголовок «Чаты» является переключателем фильтра списка.
|
||||||
|
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||||
|
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||||
|
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||||
|
|||||||
@@ -353,3 +353,6 @@ ReadReceiptBody_v1_0
|
|||||||
Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки.
|
Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки.
|
||||||
|
|
||||||
Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются.
|
Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются.
|
||||||
|
|
||||||
|
## Примечание UI списка чатов (2026-08-28)
|
||||||
|
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||||
|
|||||||
+26
-2
@@ -211,9 +211,30 @@ const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
|||||||
'settings-view',
|
'settings-view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||||
|
'access-servers-view',
|
||||||
|
'server-settings-view',
|
||||||
|
'arweave-uploads-view',
|
||||||
|
'developer-settings-view',
|
||||||
|
'trusted-device-login-settings-view',
|
||||||
|
'device-view',
|
||||||
|
'device-session-view',
|
||||||
|
'connect-device-view',
|
||||||
|
'device-pairing-view',
|
||||||
|
'device-qr-view',
|
||||||
|
'device-camera-view',
|
||||||
|
'show-keys-view',
|
||||||
|
'remote-addblock-session-view',
|
||||||
|
'app-log-view',
|
||||||
|
'pwa-diagnostics-view',
|
||||||
|
'solana-users-init-view',
|
||||||
|
'solana-rpc-check-view',
|
||||||
|
]);
|
||||||
|
|
||||||
const GUEST_ALLOWED_PAGES = new Set([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
|
'language-view',
|
||||||
'network-view',
|
'network-view',
|
||||||
'channels-list',
|
'channels-list',
|
||||||
'channel-view',
|
'channel-view',
|
||||||
@@ -1178,7 +1199,8 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page = routes[pageId] || routes['start-view'];
|
const page = routes[pageId] || routes['start-view'];
|
||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||||
|
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||||
if (typeof currentCleanup === 'function') {
|
if (typeof currentCleanup === 'function') {
|
||||||
currentCleanup();
|
currentCleanup();
|
||||||
currentCleanup = null;
|
currentCleanup = null;
|
||||||
@@ -1208,6 +1230,7 @@ function renderApp() {
|
|||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||||
|
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||||
|
|
||||||
toolbarEl.innerHTML = '';
|
toolbarEl.innerHTML = '';
|
||||||
if (showAppChrome) {
|
if (showAppChrome) {
|
||||||
@@ -1225,7 +1248,8 @@ function refreshToolbarOnly() {
|
|||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
const page = routes[pageId] || routes['start-view'];
|
const page = routes[pageId] || routes['start-view'];
|
||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||||
|
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||||
|
|
||||||
toolbarEl.innerHTML = '';
|
toolbarEl.innerHTML = '';
|
||||||
if (showAppChrome) {
|
if (showAppChrome) {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export function createDropdownMenu({
|
||||||
|
anchorEl,
|
||||||
|
items = [],
|
||||||
|
className = '',
|
||||||
|
minWidth = 210,
|
||||||
|
offset = 7,
|
||||||
|
align = 'right',
|
||||||
|
onOpen = null,
|
||||||
|
onClose = null,
|
||||||
|
} = {}) {
|
||||||
|
let portal = null;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (!portal) return;
|
||||||
|
portal.remove();
|
||||||
|
portal = null;
|
||||||
|
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = () => {
|
||||||
|
if (!portal || !anchorEl) return;
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
const margin = 10;
|
||||||
|
const menuWidth = portal.offsetWidth || minWidth;
|
||||||
|
const desiredLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||||
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||||
|
let top = rect.bottom + offset;
|
||||||
|
const menuHeight = portal.offsetHeight || 180;
|
||||||
|
if (top + menuHeight > window.innerHeight - margin) {
|
||||||
|
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||||
|
}
|
||||||
|
portal.style.left = `${Math.round(left)}px`;
|
||||||
|
portal.style.top = `${Math.round(top)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (!anchorEl || portal) return;
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.style.minWidth = `${minWidth}px`;
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item?.divider) {
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'channel-menu-divider';
|
||||||
|
menu.append(divider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||||
|
btn.setAttribute('role', 'menuitem');
|
||||||
|
if (item?.iconHtml) {
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'shared-dropdown-menu__icon';
|
||||||
|
icon.innerHTML = item.iconHtml;
|
||||||
|
btn.append(icon);
|
||||||
|
} else if (item?.iconSrc) {
|
||||||
|
const icon = document.createElement('img');
|
||||||
|
icon.src = item.iconSrc;
|
||||||
|
icon.alt = '';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.append(icon);
|
||||||
|
}
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = String(item?.label || '');
|
||||||
|
btn.append(label);
|
||||||
|
btn.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
item?.action?.();
|
||||||
|
});
|
||||||
|
menu.append(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
document.body.append(menu);
|
||||||
|
portal = menu;
|
||||||
|
anchorEl.setAttribute('aria-expanded', 'true');
|
||||||
|
onOpen?.();
|
||||||
|
position();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (portal) close();
|
||||||
|
else open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAnchorClick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
const onOutsideClick = (event) => {
|
||||||
|
if (!portal) return;
|
||||||
|
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
const onKeydown = (event) => {
|
||||||
|
if (event.key !== 'Escape' || !portal) return;
|
||||||
|
close();
|
||||||
|
anchorEl?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||||
|
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||||
|
anchorEl?.addEventListener('click', onAnchorClick);
|
||||||
|
document.addEventListener('click', onOutsideClick);
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
window.addEventListener('resize', position, { passive: true });
|
||||||
|
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
position,
|
||||||
|
destroy() {
|
||||||
|
close();
|
||||||
|
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||||
|
document.removeEventListener('click', onOutsideClick);
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
window.removeEventListener('resize', position);
|
||||||
|
window.removeEventListener('scroll', position, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -83,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
} else if (isNetwork) {
|
||||||
|
btn.innerHTML = iconHtml(item);
|
||||||
|
btn.setAttribute('aria-label', item.label);
|
||||||
|
btn.title = item.label;
|
||||||
} else {
|
} else {
|
||||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ export function render({ navigate }) {
|
|||||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { makeShineChannelRoute } from '../services/shine-routes.js';
|
|||||||
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
@@ -992,10 +993,6 @@ function openTopChannelsMenu({
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Все каналы', icon: 'all', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
|
||||||
{ label: 'Мои каналы', icon: 'mine', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
|
||||||
{ label: 'Подписки', icon: 'following', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -1191,16 +1188,24 @@ function renderChannelMain(channel) {
|
|||||||
main.append(desc);
|
main.append(desc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewLine = document.createElement('div');
|
||||||
|
previewLine.className = 'channel-row-preview-line';
|
||||||
|
|
||||||
const preview = document.createElement('p');
|
const preview = document.createElement('p');
|
||||||
preview.className = 'channel-row-message';
|
preview.className = 'channel-row-message';
|
||||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'channel-row-time';
|
||||||
|
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||||
|
previewLine.append(preview, time);
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'channel-row-owner channel-counter-meta';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.prepend(title, technical);
|
main.prepend(title, technical);
|
||||||
main.append(preview, meta);
|
main.append(previewLine, meta);
|
||||||
return main;
|
return main;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,10 +1242,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
const time = document.createElement('span');
|
|
||||||
time.className = 'channel-row-time';
|
|
||||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
|
||||||
|
|
||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
@@ -1248,7 +1249,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
|
|
||||||
controls.append(time, count);
|
controls.append(count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -1360,8 +1361,20 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
const topTitle = document.createElement('strong');
|
const topTitle = document.createElement('button');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.type = 'button';
|
||||||
|
topTitle.className = 'channels-top-title channels-filter-title';
|
||||||
|
|
||||||
|
const channelsFilterMenu = createDropdownMenu({
|
||||||
|
anchorEl: topTitle,
|
||||||
|
align: 'left',
|
||||||
|
minWidth: 220,
|
||||||
|
items: [
|
||||||
|
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||||
|
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||||
|
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const topBarRight = document.createElement('div');
|
const topBarRight = document.createElement('div');
|
||||||
topBarRight.className = 'channels-top-right';
|
topBarRight.className = 'channels-top-right';
|
||||||
@@ -1428,6 +1441,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
|
channelsFilterMenu.destroy();
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function render({ navigate }) {
|
|||||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||||
});
|
});
|
||||||
deviceToggle.addEventListener('change', () => {
|
deviceToggle.addEventListener('change', () => {
|
||||||
state.deviceConnect.device = true;
|
state.deviceConnect.client = true;
|
||||||
deviceToggle.checked = true;
|
deviceToggle.checked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,22 +100,22 @@ export function render({ navigate }) {
|
|||||||
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
const hasRoot = Boolean(savedKeys.rootKey);
|
const hasRoot = Boolean(savedKeys.rootKey);
|
||||||
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||||
const hasDevice = Boolean(savedKeys.clientKey);
|
const hasClient = Boolean(savedKeys.clientKey);
|
||||||
|
|
||||||
rootToggle.disabled = !hasRoot;
|
rootToggle.disabled = !hasRoot;
|
||||||
blockchainToggle.disabled = !hasBlockchain;
|
blockchainToggle.disabled = !hasBlockchain;
|
||||||
deviceToggle.disabled = true;
|
deviceToggle.disabled = true;
|
||||||
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||||
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||||
state.deviceConnect.device = hasDevice;
|
state.deviceConnect.client = hasClient;
|
||||||
rootToggle.checked = state.deviceConnect.root;
|
rootToggle.checked = state.deviceConnect.root;
|
||||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||||
deviceToggle.checked = hasDevice;
|
deviceToggle.checked = hasClient;
|
||||||
openQrBtn.disabled = !hasDevice;
|
openQrBtn.disabled = !hasClient;
|
||||||
openPairBtn.disabled = !hasDevice;
|
openPairBtn.disabled = !hasClient;
|
||||||
|
|
||||||
const available = [
|
const available = [
|
||||||
hasDevice ? 'device' : '',
|
hasClient ? 'client' : '',
|
||||||
hasBlockchain ? 'blockchain' : '',
|
hasBlockchain ? 'blockchain' : '',
|
||||||
hasRoot ? 'root' : '',
|
hasRoot ? 'root' : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -128,7 +128,7 @@ export function render({ navigate }) {
|
|||||||
deviceToggle.checked = false;
|
deviceToggle.checked = false;
|
||||||
state.deviceConnect.root = false;
|
state.deviceConnect.root = false;
|
||||||
state.deviceConnect.blockchain = false;
|
state.deviceConnect.blockchain = false;
|
||||||
state.deviceConnect.device = false;
|
state.deviceConnect.client = false;
|
||||||
openQrBtn.disabled = true;
|
openQrBtn.disabled = true;
|
||||||
openPairBtn.disabled = true;
|
openPairBtn.disabled = true;
|
||||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||||
|
|||||||
@@ -260,15 +260,15 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack settings-developer-card';
|
card.className = 'card stack settings-developer-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||||
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||||
<button class="text-btn" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||||
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const appLogBtn = card.querySelector('#settings-app-log');
|
const appLogBtn = card.querySelector('#settings-app-log');
|
||||||
|
|||||||
@@ -36,22 +36,20 @@ function pairingSessionKindLabel(sessionType) {
|
|||||||
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
function buildTransferKeys(savedKeys, selection = {}) {
|
||||||
const keys = {
|
const keys = {
|
||||||
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
clientKey: String(savedKeys?.clientKey || '').trim(),
|
||||||
blockchainKey: '',
|
blockchainKey: '',
|
||||||
rootKey: '',
|
rootKey: '',
|
||||||
};
|
};
|
||||||
if (!keys.clientKey) {
|
if (!keys.clientKey) {
|
||||||
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
||||||
}
|
}
|
||||||
if (withExtras) {
|
if (selection.blockchain && savedKeys?.blockchainKey) {
|
||||||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
}
|
||||||
}
|
if (selection.root && savedKeys?.rootKey) {
|
||||||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
@@ -73,10 +71,9 @@ function requestCardHtml(request) {
|
|||||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="flex-wrap:wrap;">
|
<div class="row pairing-request-actions" style="flex-wrap:wrap;">
|
||||||
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
|
<button class="primary-btn pairing-approve-btn" type="button" data-action="approve-device">Подключить</button>
|
||||||
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
|
<button class="text-btn pairing-reject-btn" type="button" data-action="reject">Отклонить</button>
|
||||||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -179,6 +176,7 @@ export function render({ navigate }) {
|
|||||||
let settingsBusy = false;
|
let settingsBusy = false;
|
||||||
let pairingPasswordConfigured = false;
|
let pairingPasswordConfigured = false;
|
||||||
let dialogMode = '';
|
let dialogMode = '';
|
||||||
|
let pendingTransferRequest = null;
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
@@ -187,6 +185,38 @@ export function render({ navigate }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const transferDialog = document.createElement('div');
|
||||||
|
transferDialog.className = 'pairing-transfer-dialog';
|
||||||
|
transferDialog.hidden = true;
|
||||||
|
transferDialog.innerHTML = `
|
||||||
|
<div class="pairing-transfer-dialog__backdrop" data-transfer-action="cancel"></div>
|
||||||
|
<section class="card stack pairing-transfer-dialog__card" role="dialog" aria-modal="true" aria-labelledby="pairing-transfer-title">
|
||||||
|
<div class="stack" style="gap:5px;">
|
||||||
|
<h2 class="login-panel-title" id="pairing-transfer-title">Подключить и передать ключи</h2>
|
||||||
|
<p class="meta-muted">Client key передаётся всегда. Дополнительные ключи можно передать только если они есть на этом устройстве.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pairing-transfer-key-list">
|
||||||
|
<label class="pairing-transfer-key is-required">
|
||||||
|
<input type="checkbox" checked disabled />
|
||||||
|
<span><strong>Client key</strong><small>Обязателен для клиентского устройства</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="pairing-transfer-key" data-transfer-key="root">
|
||||||
|
<input type="checkbox" id="pairing-transfer-root" checked />
|
||||||
|
<span><strong>Root key</strong><small>Передать на новое устройство</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="pairing-transfer-key" data-transfer-key="blockchain">
|
||||||
|
<input type="checkbox" id="pairing-transfer-blockchain" checked />
|
||||||
|
<span><strong>Blockchain key</strong><small>Передать на новое устройство</small></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row pairing-transfer-dialog__actions">
|
||||||
|
<button class="ghost-btn" type="button" data-transfer-action="cancel">Отмена</button>
|
||||||
|
<button class="primary-btn pairing-approve-btn" type="button" data-transfer-action="confirm">Подключить</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
screen.append(transferDialog);
|
||||||
|
|
||||||
const settingsCard = document.createElement('div');
|
const settingsCard = document.createElement('div');
|
||||||
settingsCard.className = 'card stack';
|
settingsCard.className = 'card stack';
|
||||||
const passwordIcons = makePasswordToggleIcons();
|
const passwordIcons = makePasswordToggleIcons();
|
||||||
@@ -432,11 +462,11 @@ export function render({ navigate }) {
|
|||||||
const loadSavedKeys = async () => {
|
const loadSavedKeys = async () => {
|
||||||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
const available = [];
|
const available = [];
|
||||||
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
|
if (savedKeys?.clientKey) available.push('client');
|
||||||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
if (savedKeys?.blockchainKey) available.push('blockchain');
|
||||||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
if (savedKeys?.rootKey) available.push('root');
|
||||||
keySummaryEl.textContent = available.length
|
keySummaryEl.textContent = available.length > 1
|
||||||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
? `Доступны для подключения: ${available.join(', ')}.`
|
||||||
: 'На этом устройстве доступен только client key.';
|
: 'На этом устройстве доступен только client key.';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,10 +490,10 @@ export function render({ navigate }) {
|
|||||||
refreshBtn.disabled = flag;
|
refreshBtn.disabled = flag;
|
||||||
};
|
};
|
||||||
|
|
||||||
const approveRequest = async (request, mode) => {
|
const approveRequest = async (request, selection = {}) => {
|
||||||
const withExtras = mode === 'with-extras';
|
const withExtras = !!selection.root || !!selection.blockchain;
|
||||||
let payload;
|
let payload;
|
||||||
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||||
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||||
@@ -477,7 +507,7 @@ export function render({ navigate }) {
|
|||||||
session: delegatedSession,
|
session: delegatedSession,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
const keys = buildTransferKeys(savedKeys, selection);
|
||||||
payload = buildSecretsPayload({
|
payload = buildSecretsPayload({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
keys,
|
keys,
|
||||||
@@ -486,7 +516,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||||
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
||||||
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||||
showToast(
|
showToast(
|
||||||
withExtras
|
withExtras
|
||||||
? 'Ключи переданы на новое устройство'
|
? 'Ключи переданы на новое устройство'
|
||||||
@@ -505,6 +535,53 @@ export function render({ navigate }) {
|
|||||||
await reloadRequests({ silent: true });
|
await reloadRequests({ silent: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeTransferDialog = () => {
|
||||||
|
pendingTransferRequest = null;
|
||||||
|
transferDialog.hidden = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTransferDialog = (request) => {
|
||||||
|
pendingTransferRequest = request;
|
||||||
|
const rootRow = transferDialog.querySelector('[data-transfer-key="root"]');
|
||||||
|
const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]');
|
||||||
|
const rootInput = transferDialog.querySelector('#pairing-transfer-root');
|
||||||
|
const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain');
|
||||||
|
const hasRoot = !!String(savedKeys?.rootKey || '').trim();
|
||||||
|
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
|
||||||
|
rootRow.hidden = !hasRoot;
|
||||||
|
blockchainRow.hidden = !hasBlockchain;
|
||||||
|
rootInput.checked = hasRoot;
|
||||||
|
blockchainInput.checked = hasBlockchain;
|
||||||
|
transferDialog.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
transferDialog.addEventListener('click', async (event) => {
|
||||||
|
const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null;
|
||||||
|
const action = String(target?.dataset?.transferAction || '');
|
||||||
|
if (!action) return;
|
||||||
|
if (action === 'cancel') {
|
||||||
|
closeTransferDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action !== 'confirm' || !pendingTransferRequest) return;
|
||||||
|
const request = pendingTransferRequest;
|
||||||
|
const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]');
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await approveRequest(request, {
|
||||||
|
root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked,
|
||||||
|
blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked,
|
||||||
|
});
|
||||||
|
closeTransferDialog();
|
||||||
|
} catch (error) {
|
||||||
|
const message = toUserMessage(error, 'Не удалось подключить устройство.');
|
||||||
|
setAuthError(message);
|
||||||
|
setStatus(status, message, 'error');
|
||||||
|
} finally {
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
passwordDialog.addEventListener('click', (event) => {
|
passwordDialog.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@@ -609,9 +686,14 @@ export function render({ navigate }) {
|
|||||||
buttons.forEach((btn) => { btn.disabled = true; });
|
buttons.forEach((btn) => { btn.disabled = true; });
|
||||||
try {
|
try {
|
||||||
if (action === 'approve-device') {
|
if (action === 'approve-device') {
|
||||||
await approveRequest(request, 'device-only');
|
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||||
} else if (action === 'approve-full') {
|
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
|
||||||
await approveRequest(request, 'with-extras');
|
if (!isWallet && hasExtras) {
|
||||||
|
openTransferDialog(request);
|
||||||
|
buttons.forEach((btn) => { btn.disabled = false; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await approveRequest(request, {});
|
||||||
} else if (action === 'reject') {
|
} else if (action === 'reject') {
|
||||||
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
||||||
showToast('Заявка отклонена', { kind: 'error' });
|
showToast('Заявка отклонена', { kind: 'error' });
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function render({ navigate, route }) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'text-btn';
|
actionBtn.className = 'shine-btn shine-btn--settings';
|
||||||
actionBtn.type = 'button';
|
actionBtn.type = 'button';
|
||||||
actionBtn.textContent = 'Завершить сеанс';
|
actionBtn.textContent = 'Завершить сеанс';
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ export function render({ navigate }) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'card stack';
|
actions.className = 'card stack';
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||||
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||||
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||||
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
||||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ export function render({ navigate }) {
|
|||||||
currentMenu.append(createSessionItem(current, true));
|
currentMenu.append(createSessionItem(current, true));
|
||||||
|
|
||||||
const endCurrentSessionBtn = document.createElement('button');
|
const endCurrentSessionBtn = document.createElement('button');
|
||||||
endCurrentSessionBtn.className = 'text-btn';
|
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||||
endCurrentSessionBtn.type = 'button';
|
endCurrentSessionBtn.type = 'button';
|
||||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -33,24 +33,6 @@ export function render({ navigate }) {
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'card stack';
|
body.className = 'card stack';
|
||||||
|
|
||||||
const languageLabel = document.createElement('label');
|
|
||||||
languageLabel.className = 'stack';
|
|
||||||
languageLabel.innerHTML = `<span class="field-label">Язык</span>`;
|
|
||||||
|
|
||||||
const languageSelect = document.createElement('select');
|
|
||||||
languageSelect.className = 'select';
|
|
||||||
languageSelect.innerHTML = `
|
|
||||||
<option value="ru">Русский</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
`;
|
|
||||||
languageSelect.value = draft.language;
|
|
||||||
languageSelect.addEventListener('change', () => {
|
|
||||||
draft.language = languageSelect.value;
|
|
||||||
});
|
|
||||||
languageLabel.append(languageSelect);
|
|
||||||
|
|
||||||
body.append(languageLabel);
|
|
||||||
|
|
||||||
SERVER_FIELDS.forEach((field) => {
|
SERVER_FIELDS.forEach((field) => {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
block.className = 'stack';
|
block.className = 'stack';
|
||||||
|
|||||||
@@ -1,43 +1,58 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { saveEntryLanguage, state } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||||
|
|
||||||
|
function resolveReturnPage() {
|
||||||
|
const stored = String(sessionStorage.getItem('shine-language-return-page') || '').trim();
|
||||||
|
return stored === 'start-view' ? 'start-view' : 'settings-view';
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack language-screen';
|
||||||
|
const returnPage = resolveReturnPage();
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Язык',
|
title: 'Язык / Language',
|
||||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
leftAction: { label: '←', onClick: () => {
|
||||||
|
sessionStorage.removeItem('shine-language-return-page');
|
||||||
|
navigate(returnPage);
|
||||||
|
} },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
card.className = 'card stack language-choice-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
<div class="language-choice-grid" role="radiogroup" aria-label="Язык интерфейса">
|
||||||
|
<button class="language-choice-option${state.entrySettings.language === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${state.entrySettings.language === 'ru'}">
|
||||||
|
<span class="language-choice-name">Русский</span>
|
||||||
|
<span class="language-choice-code">RU</span>
|
||||||
|
</button>
|
||||||
|
<button class="language-choice-option${state.entrySettings.language === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${state.entrySettings.language === 'en'}">
|
||||||
|
<span class="language-choice-name">English</span>
|
||||||
|
<span class="language-choice-code">EN</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||||
actions.className = 'auth-footer-actions';
|
button.addEventListener('click', () => {
|
||||||
actions.innerHTML = `
|
const language = String(button.dataset.language || 'ru');
|
||||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
saveEntryLanguage(language);
|
||||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||||
`;
|
const selected = item === button;
|
||||||
|
item.classList.toggle('is-selected', selected);
|
||||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
item.setAttribute('aria-checked', String(selected));
|
||||||
const selected = card.querySelector('input[name="language"]:checked');
|
});
|
||||||
if (selected) {
|
sessionStorage.removeItem('shine-language-return-page');
|
||||||
state.entrySettings.language = selected.value;
|
navigate(returnPage);
|
||||||
}
|
});
|
||||||
navigate('settings-view');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
|
screen.append(card);
|
||||||
|
|
||||||
screen.append(card, actions);
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
@@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack auth-screen auth-screen--lower';
|
screen.className = 'stack auth-screen auth-screen--other-device';
|
||||||
let pollTimer = 0;
|
let pollTimer = 0;
|
||||||
let countdownTimer = 0;
|
let countdownTimer = 0;
|
||||||
let activePairingId = '';
|
let activePairingId = '';
|
||||||
@@ -70,16 +69,16 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
clearAuthMessages();
|
clearAuthMessages();
|
||||||
|
|
||||||
screen.append(
|
|
||||||
renderHeader({
|
|
||||||
title: 'Войти через другое устройство',
|
|
||||||
leftAction: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const panel = document.createElement('section');
|
const panel = document.createElement('section');
|
||||||
panel.className = 'login-panel login-panel--wide stack';
|
panel.className = 'login-panel login-panel--wide stack';
|
||||||
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
|
panel.innerHTML = `
|
||||||
|
<button class="login-panel-inline-back" type="button" id="login-other-device-back" aria-label="Назад">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
<span>Войти через другое устройство</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); });
|
||||||
|
|
||||||
const formCard = document.createElement('div');
|
const formCard = document.createElement('div');
|
||||||
formCard.className = 'card stack login-device-preparation';
|
formCard.className = 'card stack login-device-preparation';
|
||||||
@@ -183,7 +182,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
@@ -216,7 +215,7 @@ export function render({ navigate }) {
|
|||||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
@@ -339,7 +338,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const PREVIEW_MAX_LEN = 200;
|
const PREVIEW_MAX_LEN = 200;
|
||||||
@@ -172,14 +174,7 @@ function latestLocalDialogMessage(peerLogin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatChatRowTime(ts) {
|
function formatChatRowTime(ts) {
|
||||||
const value = Number(ts || 0);
|
return formatRelativeTime(ts);
|
||||||
if (!Number.isFinite(value) || value <= 0) return '';
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareChatRows(a, b) {
|
function compareChatRows(a, b) {
|
||||||
@@ -200,7 +195,7 @@ export function render({ navigate, chrome }) {
|
|||||||
<div class="dm-head-brand">
|
<div class="dm-head-brand">
|
||||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="dm-head-title">Чаты</h1>
|
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
||||||
<div class="dm-head-menu-wrap">
|
<div class="dm-head-menu-wrap">
|
||||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||||
<div class="dm-head-menu" role="menu" hidden>
|
<div class="dm-head-menu" role="menu" hidden>
|
||||||
@@ -220,6 +215,27 @@ export function render({ navigate, chrome }) {
|
|||||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
menuButton?.append(createOverflowDots());
|
menuButton?.append(createOverflowDots());
|
||||||
|
|
||||||
|
let currentChatFilter = 'all';
|
||||||
|
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
||||||
|
const filterLabels = {
|
||||||
|
all: 'Чаты',
|
||||||
|
close_friend: 'Близкие друзья',
|
||||||
|
contact: 'Контакты',
|
||||||
|
none: 'Новые',
|
||||||
|
};
|
||||||
|
let reloadForFilter = () => {};
|
||||||
|
const chatFilterMenu = createDropdownMenu({
|
||||||
|
anchorEl: filterTitle,
|
||||||
|
align: 'left',
|
||||||
|
minWidth: 225,
|
||||||
|
items: [
|
||||||
|
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||||
|
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||||
|
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||||
|
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||||
@@ -395,12 +411,14 @@ function renderRow(item) {
|
|||||||
byPeer.set(key, newest);
|
byPeer.set(key, newest);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
const rows = Array.from(byPeer.values())
|
||||||
const orderA = relationOrder(a.relationFlag);
|
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||||
const orderB = relationOrder(b.relationFlag);
|
.sort((a, b) => {
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
const orderA = relationOrder(a.relationFlag);
|
||||||
return compareChatRows(a, b);
|
const orderB = relationOrder(b.relationFlag);
|
||||||
});
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return compareChatRows(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
@@ -458,12 +476,15 @@ function renderRow(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reloadForFilter = () => { void loadList(); };
|
||||||
|
|
||||||
chrome?.setTopbar(head);
|
chrome?.setTopbar(head);
|
||||||
screen.append(list);
|
screen.append(list);
|
||||||
loadList();
|
loadList();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeHeadMenu();
|
closeHeadMenu();
|
||||||
|
chatFilterMenu.destroy();
|
||||||
document.removeEventListener('click', onOutsideClick);
|
document.removeEventListener('click', onOutsideClick);
|
||||||
document.removeEventListener('keydown', onMenuKeydown);
|
document.removeEventListener('keydown', onMenuKeydown);
|
||||||
window.removeEventListener('resize', onMenuViewportChange);
|
window.removeEventListener('resize', onMenuViewportChange);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@@ -110,84 +111,19 @@ export function render({ navigate, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
|
||||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
profileMenuButton?.append(createOverflowDots());
|
profileMenuButton?.append(createOverflowDots());
|
||||||
let profileMenuPortal = null;
|
const profileMenu = createDropdownMenu({
|
||||||
|
anchorEl: profileMenuButton,
|
||||||
const closeProfileMenu = () => {
|
className: 'profile-head-menu',
|
||||||
profileMenuPortal?.remove();
|
minWidth: 230,
|
||||||
profileMenuPortal = null;
|
items: [
|
||||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||||
profileMenuWrap?.classList.remove('is-open');
|
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||||
};
|
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||||
|
],
|
||||||
const positionProfileMenu = () => {
|
|
||||||
if (!profileMenuPortal || !profileMenuButton) return;
|
|
||||||
const rect = profileMenuButton.getBoundingClientRect();
|
|
||||||
const margin = 10;
|
|
||||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
|
||||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
|
||||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
|
||||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openProfileMenu = () => {
|
|
||||||
if (!profileMenuButton || profileMenuPortal) return;
|
|
||||||
const portal = document.createElement('div');
|
|
||||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
|
||||||
portal.setAttribute('role', 'menu');
|
|
||||||
portal.innerHTML = `
|
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
|
||||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
|
||||||
<span>Редактировать профиль</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
|
||||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
|
||||||
<span>Кошелёк</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
|
||||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
|
||||||
<span>Настройки</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const goTo = (route) => {
|
|
||||||
closeProfileMenu();
|
|
||||||
navigate(route);
|
|
||||||
};
|
|
||||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
|
||||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
|
||||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
|
||||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
|
||||||
|
|
||||||
document.body.append(portal);
|
|
||||||
profileMenuPortal = portal;
|
|
||||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
|
||||||
profileMenuWrap?.classList.add('is-open');
|
|
||||||
positionProfileMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
profileMenuButton?.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (profileMenuPortal) closeProfileMenu();
|
|
||||||
else openProfileMenu();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', (event) => {
|
|
||||||
if (!profileMenuPortal) return;
|
|
||||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
|
||||||
closeProfileMenu();
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
|
||||||
closeProfileMenu();
|
|
||||||
profileMenuButton?.focus();
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
|
||||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
|
||||||
|
|
||||||
chrome?.setTopbar(topbar);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -382,5 +318,6 @@ export function render({ navigate, chrome }) {
|
|||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
|
screen.cleanup = () => profileMenu.destroy();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ export const REGISTRATION_FAQ_TOPICS = [
|
|||||||
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'connect-device',
|
||||||
|
shortTitle: 'Подключить по коду',
|
||||||
|
title: 'Как подключить другое устройство?',
|
||||||
|
paragraphs: [
|
||||||
|
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
|
||||||
|
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
|
||||||
|
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'hardware-device',
|
id: 'hardware-device',
|
||||||
shortTitle: 'ESP32',
|
shortTitle: 'ESP32',
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const deviceRow = createKeyInfo(
|
const deviceRow = createKeyInfo(
|
||||||
deviceToggle,
|
deviceToggle,
|
||||||
'Ключ device (всегда)',
|
'Client key (всегда)',
|
||||||
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
|
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,28 +40,28 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
card.className = 'card stack';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
|
||||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||||
<button class="text-btn" type="button" id="settings-access-servers">
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||||
<span style="display:block; text-align:left;">
|
<span style="display:block; text-align:left;">
|
||||||
<strong>Сервер доступа</strong>
|
<strong>Сервер доступа</strong>
|
||||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="text-btn" type="button" id="settings-blockchain-servers">
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers">
|
||||||
<span style="display:block; text-align:left;">
|
<span style="display:block; text-align:left;">
|
||||||
<strong>Серверы блокчейнов</strong>
|
<strong>Серверы блокчейнов</strong>
|
||||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="text-btn" type="button" id="settings-arweave-uploads">
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
|
||||||
<span style="display:block; text-align:left;">
|
<span style="display:block; text-align:left;">
|
||||||
<strong>Загрузить файлы в блокчейн</strong>
|
<strong>Загрузить файлы в блокчейн</strong>
|
||||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
|
||||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||||
@@ -69,7 +69,10 @@ export function render({ navigate }) {
|
|||||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||||
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||||
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
|
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
|
||||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
card.querySelector('#settings-language').addEventListener('click', () => {
|
||||||
|
sessionStorage.setItem('shine-language-return-page', 'settings-view');
|
||||||
|
navigate('language-view');
|
||||||
|
});
|
||||||
|
|
||||||
const signOutBtn = card.querySelector('#settings-signout');
|
const signOutBtn = card.querySelector('#settings-signout');
|
||||||
signOutBtn.addEventListener('click', async () => {
|
signOutBtn.addEventListener('click', async () => {
|
||||||
@@ -117,7 +120,7 @@ export function render({ navigate }) {
|
|||||||
const developerCard = document.createElement('div');
|
const developerCard = document.createElement('div');
|
||||||
developerCard.className = 'card stack';
|
developerCard.className = 'card stack';
|
||||||
developerCard.innerHTML = `
|
developerCard.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-developer">Настройки разработчика</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
|
||||||
`;
|
`;
|
||||||
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
|
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ export function render({ navigate }) {
|
|||||||
registerButton.textContent = 'Зарегистрироваться';
|
registerButton.textContent = 'Зарегистрироваться';
|
||||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
const guestViewButton = document.createElement('button');
|
const languageButton = document.createElement('button');
|
||||||
guestViewButton.className = 'shine-btn shine-btn--view';
|
languageButton.className = 'shine-btn shine-btn--view';
|
||||||
guestViewButton.type = 'button';
|
languageButton.type = 'button';
|
||||||
guestViewButton.textContent = 'Только просмотр';
|
languageButton.textContent = 'Язык / Language';
|
||||||
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
languageButton.addEventListener('click', () => {
|
||||||
|
sessionStorage.setItem('shine-language-return-page', 'start-view');
|
||||||
|
navigate('language-view');
|
||||||
|
});
|
||||||
|
|
||||||
const settingsButton = document.createElement('button');
|
const settingsButton = document.createElement('button');
|
||||||
settingsButton.className = 'shine-btn shine-btn--settings';
|
settingsButton.className = 'shine-btn shine-btn--settings';
|
||||||
@@ -48,7 +51,7 @@ export function render({ navigate }) {
|
|||||||
settingsButton.textContent = 'Настройки';
|
settingsButton.textContent = 'Настройки';
|
||||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||||
|
|
||||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
actions.append(loginButton, registerButton, languageButton, settingsButton);
|
||||||
screen.append(logoWrap, title, actions);
|
screen.append(logoWrap, title, actions);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const rtf = (() => {
|
const rtf = (() => {
|
||||||
try {
|
try {
|
||||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
|
return new Intl.RelativeTimeFormat('ru', { numeric: 'always' });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,41 +33,30 @@ export function formatRelativeTime(timestampMs) {
|
|||||||
if (!ts) return '—';
|
if (!ts) return '—';
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const dt = new Date(ts);
|
||||||
|
const nowDt = new Date(now);
|
||||||
const diffSeconds = (ts - now) / 1000;
|
const diffSeconds = (ts - now) / 1000;
|
||||||
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
|
const ageSeconds = Math.max(0, (now - ts) / 1000);
|
||||||
const ageHours = ageSeconds / 3600;
|
const ageDays = ageSeconds / 86400;
|
||||||
|
|
||||||
if (ageHours <= 10) {
|
if (ageDays < 7) {
|
||||||
const [unit, value] = pickUnit(diffSeconds);
|
const [unit, value] = pickUnit(diffSeconds);
|
||||||
if (rtf) return rtf.format(value, unit);
|
if (rtf) return rtf.format(value, unit);
|
||||||
|
|
||||||
const absValue = Math.abs(value);
|
const absValue = Math.abs(value);
|
||||||
const suffix = value <= 0 ? 'назад' : 'через';
|
const suffix = value <= 0 ? 'назад' : 'через';
|
||||||
const labels = {
|
const labels = { second: 'сек', minute: 'мин', hour: 'ч', day: 'д' };
|
||||||
second: 'сек',
|
return `${absValue} ${labels[unit] || ''} ${suffix}`.trim();
|
||||||
minute: 'мин',
|
|
||||||
hour: 'ч',
|
|
||||||
day: 'д',
|
|
||||||
month: 'мес',
|
|
||||||
year: 'г',
|
|
||||||
};
|
|
||||||
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (dt.getFullYear() === nowDt.getFullYear()) {
|
||||||
const dt = new Date(ts);
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
const nowDt = new Date(now);
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
const formatter = new Intl.DateTimeFormat('ru-RU', {
|
}).format(dt).replace(',', ',');
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
return formatter.format(dt);
|
|
||||||
} catch {
|
|
||||||
return new Date(ts).toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
}).format(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureToastHost() {
|
function ensureToastHost() {
|
||||||
|
|||||||
+10
-5
@@ -300,15 +300,15 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
keyStorage: {
|
keyStorage: {
|
||||||
rootKey: 'Ключ root хранится в зашифрованном виде',
|
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||||
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||||
clientKey: 'Ключ device хранится в зашифрованном виде',
|
clientKey: 'Client key хранится в зашифрованном виде',
|
||||||
saveRoot: true,
|
saveRoot: true,
|
||||||
saveBlockchain: true,
|
saveBlockchain: true,
|
||||||
saveDevice: true,
|
saveClient: true,
|
||||||
},
|
},
|
||||||
deviceConnect: {
|
deviceConnect: {
|
||||||
root: true,
|
root: true,
|
||||||
blockchain: true,
|
blockchain: true,
|
||||||
device: true,
|
client: true,
|
||||||
},
|
},
|
||||||
authUi: {
|
authUi: {
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -848,6 +848,11 @@ export function checkServerAvailability(address) {
|
|||||||
return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable';
|
return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveEntryLanguage(language) {
|
||||||
|
state.entrySettings.language = String(language || 'ru');
|
||||||
|
persistEntrySettings(state.entrySettings);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveEntrySettings(nextSettings) {
|
export async function saveEntrySettings(nextSettings) {
|
||||||
const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER);
|
const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER);
|
||||||
const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase()
|
const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase()
|
||||||
@@ -1013,7 +1018,7 @@ async function tryCloseCurrentSessionOnServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
||||||
if (closeServerSession) {
|
if (closeServerSession) {
|
||||||
await tryCloseCurrentSessionOnServer();
|
await tryCloseCurrentSessionOnServer();
|
||||||
}
|
}
|
||||||
@@ -1031,7 +1036,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore reconnect errors on sign out
|
// ignore reconnect errors on sign out
|
||||||
}
|
}
|
||||||
if (onSessionReset) {
|
if (notifySessionReset && onSessionReset) {
|
||||||
onSessionReset();
|
onSessionReset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,3 +316,62 @@
|
|||||||
:root button.dm-message-action-btn--danger {
|
:root button.dm-message-action-btn--danger {
|
||||||
color: #ffb7c5 !important;
|
color: #ffb7c5 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 2026-08-28: исключения из borderless-reset для явных выборов и действий настроек. */
|
||||||
|
:root body .language-choice-grid button.language-choice-option,
|
||||||
|
:root body .language-choice-grid button.language-choice-option:hover,
|
||||||
|
:root body .language-choice-grid button.language-choice-option:focus {
|
||||||
|
border: 1px solid rgba(210, 222, 241, 0.16) !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.035) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body .language-choice-grid button.language-choice-option.is-selected,
|
||||||
|
:root body .language-choice-grid button.language-choice-option.is-selected:hover,
|
||||||
|
:root body .language-choice-grid button.language-choice-option.is-selected:focus {
|
||||||
|
border-color: rgba(92, 190, 255, 0.62) !important;
|
||||||
|
background: rgba(39, 141, 255, 0.12) !important;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body button.pairing-approve-btn,
|
||||||
|
:root body button.pairing-approve-btn:hover,
|
||||||
|
:root body button.pairing-approve-btn:focus {
|
||||||
|
background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important;
|
||||||
|
border: 1px solid rgba(126, 235, 170, .55) !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body button.pairing-reject-btn,
|
||||||
|
:root body button.pairing-reject-btn:hover,
|
||||||
|
:root body button.pairing-reject-btn:focus {
|
||||||
|
color: #fff1f3 !important;
|
||||||
|
background: rgba(134, 31, 49, .28) !important;
|
||||||
|
border: 1px solid rgba(255, 105, 128, .42) !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) {
|
||||||
|
border: 1px solid rgba(183, 203, 235, 0.28) !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%),
|
||||||
|
rgba(8, 19, 42, .58) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,.10),
|
||||||
|
0 5px 16px rgba(0,0,0,.18) !important;
|
||||||
|
padding-inline: 14px;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover {
|
||||||
|
border-color: rgba(213, 225, 247, 0.42) !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%),
|
||||||
|
rgba(10, 24, 52, .68) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10008,3 +10008,309 @@ body.chat-topbar-overlay .composer-slot {
|
|||||||
@keyframes secret-generation-spin {
|
@keyframes secret-generation-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 2026-08-28: единое dropdown-меню для чатов / каналов / профиля ===== */
|
||||||
|
.shared-dropdown-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 12000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-dropdown-menu__item.is-selected {
|
||||||
|
background: rgba(39, 141, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-dropdown-menu__icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-dropdown-menu__icon > svg,
|
||||||
|
.shared-dropdown-menu__item > img {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
color: #65b7ff;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-head-menu .shared-dropdown-menu__item > img {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
min-width: 23px;
|
||||||
|
flex-basis: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-head-filter-title,
|
||||||
|
.channels-filter-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-head-filter-title::after,
|
||||||
|
.channels-filter-title::after {
|
||||||
|
content: '⌄';
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Выбор языка со стартового экрана ===== */
|
||||||
|
.language-screen {
|
||||||
|
width: min(100%, 430px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(38, 43, 52, 0.74);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-hint {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-option {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid rgba(210, 222, 241, 0.16) !important;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.035) !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-option.is-selected {
|
||||||
|
border-color: rgba(92, 190, 255, 0.62) !important;
|
||||||
|
background: rgba(39, 141, 255, 0.12) !important;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-choice-code {
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Вход через другое устройство: заголовок внутри панели ===== */
|
||||||
|
.auth-screen--other-device {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: max(18px, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-screen--other-device .login-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-inline-back {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-inline-back > span:first-child {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pairing: простое Подключить / Отклонить + выбор доступных ключей ===== */
|
||||||
|
:root button.pairing-approve-btn,
|
||||||
|
:root button.pairing-approve-btn:hover,
|
||||||
|
:root button.pairing-approve-btn:focus {
|
||||||
|
background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important;
|
||||||
|
border: 1px solid rgba(126, 235, 170, .55) !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root button.pairing-reject-btn,
|
||||||
|
:root button.pairing-reject-btn:hover,
|
||||||
|
:root button.pairing-reject-btn:focus {
|
||||||
|
color: #fff1f3 !important;
|
||||||
|
background: rgba(134, 31, 49, .28) !important;
|
||||||
|
border: 1px solid rgba(255, 105, 128, .42) !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-request-actions > button {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 13000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-dialog__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(3, 7, 15, .76);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-dialog__card {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: min(calc(100vw - 28px), 390px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 16px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-key-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-key {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,.11);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-key span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-key small {
|
||||||
|
color: rgba(255,255,255,.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-key.is-required {
|
||||||
|
opacity: .82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-transfer-dialog__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Каналы: плотные широкие строки, badge сверху, время на линии preview ===== */
|
||||||
|
.channels-screen--list .channels-list-content,
|
||||||
|
.channels-screen--list .channels-groups {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 64px;
|
||||||
|
margin: 2px 4px;
|
||||||
|
padding: 5px 8px 5px 5px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row .avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-main {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 6px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-preview-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-preview-line .channel-row-message {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-preview-line .channel-row-time {
|
||||||
|
align-self: baseline;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 7px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-count,
|
||||||
|
.channels-screen--list .unread.channel-row-count {
|
||||||
|
position: static !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Вложенные настройки: тот же рамочный язык действий ===== */
|
||||||
|
:root .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) {
|
||||||
|
border: 1px solid rgba(183, 203, 235, 0.28) !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%),
|
||||||
|
rgba(8, 19, 42, .58) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,.10),
|
||||||
|
0 5px 16px rgba(0,0,0,.18) !important;
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover {
|
||||||
|
border-color: rgba(213, 225, 247, 0.42) !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%),
|
||||||
|
rgba(10, 24, 52, .68) !important;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user