Обновить UI и документы личных сообщений

This commit is contained in:
AidarKC
2026-08-29 15:12:13 +04:00
parent 357a05f7ec
commit b47440dd72
26 changed files with 851 additions and 257 deletions
+1 -1
View File
@@ -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
View File
@@ -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) {
+130
View File
@@ -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);
},
};
}
+4
View File
@@ -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>`;
} }
+3 -3
View File
@@ -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());
+26 -12
View File
@@ -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');
}; };
+8 -8
View File
@@ -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 = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
+9 -9
View File
@@ -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');
+108 -26
View File
@@ -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' });
+1 -1
View File
@@ -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 = 'Завершить сеанс';
+6 -4
View File
@@ -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 () => {
-18
View File
@@ -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';
+38 -23
View File
@@ -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;
} }
+12 -13
View File
@@ -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
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.' ? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.'; : 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
+36 -15
View File
@@ -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);
+11 -74
View File
@@ -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',
+1 -1
View File
@@ -86,7 +86,7 @@ export function render({ navigate }) {
const deviceRow = createKeyInfo( const deviceRow = createKeyInfo(
deviceToggle, deviceToggle,
'Ключ device (всегда)', 'Client key (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.', 'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
); );
+12 -9
View File
@@ -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'));
+9 -6
View File
@@ -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;
} }
+16 -27
View File
@@ -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
View File
@@ -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();
} }
} }
+59
View File
@@ -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;
}
+306
View File
@@ -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;
}