SHA256
Обновить UI и документы личных сообщений
This commit is contained in:
+26
-2
@@ -211,9 +211,30 @@ const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
'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([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
'language-view',
|
||||
'network-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
@@ -1178,7 +1199,8 @@ function renderApp() {
|
||||
}
|
||||
|
||||
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') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
@@ -1208,6 +1230,7 @@ function renderApp() {
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
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('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
@@ -1225,7 +1248,8 @@ function refreshToolbarOnly() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : '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 = '';
|
||||
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>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = iconHtml(item);
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
} else {
|
||||
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="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" 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="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
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 { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -992,10 +993,6 @@ function openTopChannelsMenu({
|
||||
const items = [
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ 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) => {
|
||||
@@ -1191,16 +1188,24 @@ function renderChannelMain(channel) {
|
||||
main.append(desc);
|
||||
}
|
||||
|
||||
const previewLine = document.createElement('div');
|
||||
previewLine.className = 'channel-row-preview-line';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
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');
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title, technical);
|
||||
main.append(preview, meta);
|
||||
main.append(previewLine, meta);
|
||||
return main;
|
||||
}
|
||||
|
||||
@@ -1237,10 +1242,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const controls = document.createElement('div');
|
||||
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');
|
||||
count.className = 'unread channel-row-count';
|
||||
const unreadCount = Number(channel.unreadCount || 0);
|
||||
@@ -1248,7 +1249,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
controls.append(time, count);
|
||||
controls.append(count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => {
|
||||
@@ -1360,8 +1361,20 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
const topTitle = document.createElement('button');
|
||||
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');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
@@ -1428,6 +1441,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function render({ navigate }) {
|
||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||
});
|
||||
deviceToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.device = true;
|
||||
state.deviceConnect.client = true;
|
||||
deviceToggle.checked = true;
|
||||
});
|
||||
|
||||
@@ -100,22 +100,22 @@ export function render({ navigate }) {
|
||||
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const hasRoot = Boolean(savedKeys.rootKey);
|
||||
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||
const hasDevice = Boolean(savedKeys.clientKey);
|
||||
const hasClient = Boolean(savedKeys.clientKey);
|
||||
|
||||
rootToggle.disabled = !hasRoot;
|
||||
blockchainToggle.disabled = !hasBlockchain;
|
||||
deviceToggle.disabled = true;
|
||||
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||
state.deviceConnect.device = hasDevice;
|
||||
state.deviceConnect.client = hasClient;
|
||||
rootToggle.checked = state.deviceConnect.root;
|
||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||
deviceToggle.checked = hasDevice;
|
||||
openQrBtn.disabled = !hasDevice;
|
||||
openPairBtn.disabled = !hasDevice;
|
||||
deviceToggle.checked = hasClient;
|
||||
openQrBtn.disabled = !hasClient;
|
||||
openPairBtn.disabled = !hasClient;
|
||||
|
||||
const available = [
|
||||
hasDevice ? 'device' : '',
|
||||
hasClient ? 'client' : '',
|
||||
hasBlockchain ? 'blockchain' : '',
|
||||
hasRoot ? 'root' : '',
|
||||
].filter(Boolean);
|
||||
@@ -128,7 +128,7 @@ export function render({ navigate }) {
|
||||
deviceToggle.checked = false;
|
||||
state.deviceConnect.root = false;
|
||||
state.deviceConnect.blockchain = false;
|
||||
state.deviceConnect.device = false;
|
||||
state.deviceConnect.client = false;
|
||||
openQrBtn.disabled = true;
|
||||
openPairBtn.disabled = true;
|
||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||
|
||||
@@ -260,15 +260,15 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="text-btn" 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="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="text-btn" 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-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
`;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
||||
function buildTransferKeys(savedKeys, selection = {}) {
|
||||
const keys = {
|
||||
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
clientKey: String(savedKeys?.clientKey || '').trim(),
|
||||
blockchainKey: '',
|
||||
rootKey: '',
|
||||
};
|
||||
if (!keys.clientKey) {
|
||||
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
||||
}
|
||||
if (withExtras) {
|
||||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
if (selection.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (selection.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -73,10 +71,9 @@ function requestCardHtml(request) {
|
||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="flex-wrap:wrap;">
|
||||
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
|
||||
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
|
||||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
||||
<div class="row pairing-request-actions" style="flex-wrap:wrap;">
|
||||
<button class="primary-btn pairing-approve-btn" type="button" data-action="approve-device">Подключить</button>
|
||||
<button class="text-btn pairing-reject-btn" type="button" data-action="reject">Отклонить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -179,6 +176,7 @@ export function render({ navigate }) {
|
||||
let settingsBusy = false;
|
||||
let pairingPasswordConfigured = false;
|
||||
let dialogMode = '';
|
||||
let pendingTransferRequest = null;
|
||||
|
||||
screen.append(
|
||||
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');
|
||||
settingsCard.className = 'card stack';
|
||||
const passwordIcons = makePasswordToggleIcons();
|
||||
@@ -432,11 +462,11 @@ export function render({ navigate }) {
|
||||
const loadSavedKeys = async () => {
|
||||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const available = [];
|
||||
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
||||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
||||
keySummaryEl.textContent = available.length
|
||||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
||||
if (savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey) available.push('blockchain');
|
||||
if (savedKeys?.rootKey) available.push('root');
|
||||
keySummaryEl.textContent = available.length > 1
|
||||
? `Доступны для подключения: ${available.join(', ')}.`
|
||||
: 'На этом устройстве доступен только client key.';
|
||||
};
|
||||
|
||||
@@ -460,10 +490,10 @@ export function render({ navigate }) {
|
||||
refreshBtn.disabled = flag;
|
||||
};
|
||||
|
||||
const approveRequest = async (request, mode) => {
|
||||
const withExtras = mode === 'with-extras';
|
||||
const approveRequest = async (request, selection = {}) => {
|
||||
const withExtras = !!selection.root || !!selection.blockchain;
|
||||
let payload;
|
||||
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
||||
login: state.session.login,
|
||||
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
@@ -477,7 +507,7 @@ export function render({ navigate }) {
|
||||
session: delegatedSession,
|
||||
});
|
||||
} else {
|
||||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
||||
const keys = buildTransferKeys(savedKeys, selection);
|
||||
payload = buildSecretsPayload({
|
||||
login: state.session.login,
|
||||
keys,
|
||||
@@ -486,7 +516,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||
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(
|
||||
withExtras
|
||||
? 'Ключи переданы на новое устройство'
|
||||
@@ -505,6 +535,53 @@ export function render({ navigate }) {
|
||||
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) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -609,9 +686,14 @@ export function render({ navigate }) {
|
||||
buttons.forEach((btn) => { btn.disabled = true; });
|
||||
try {
|
||||
if (action === 'approve-device') {
|
||||
await approveRequest(request, 'device-only');
|
||||
} else if (action === 'approve-full') {
|
||||
await approveRequest(request, 'with-extras');
|
||||
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
|
||||
if (!isWallet && hasExtras) {
|
||||
openTransferDialog(request);
|
||||
buttons.forEach((btn) => { btn.disabled = false; });
|
||||
return;
|
||||
}
|
||||
await approveRequest(request, {});
|
||||
} else if (action === 'reject') {
|
||||
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
||||
showToast('Заявка отклонена', { kind: 'error' });
|
||||
|
||||
@@ -68,7 +68,7 @@ export function render({ navigate, route }) {
|
||||
`;
|
||||
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'text-btn';
|
||||
actionBtn.className = 'shine-btn shine-btn--settings';
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.textContent = 'Завершить сеанс';
|
||||
|
||||
|
||||
@@ -54,11 +54,13 @@ export function render({ navigate }) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="ghost-btn" type="button" id="connect-device-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="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-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('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
@@ -109,7 +111,7 @@ export function render({ navigate }) {
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'text-btn';
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
|
||||
@@ -33,24 +33,6 @@ export function render({ navigate }) {
|
||||
const body = document.createElement('div');
|
||||
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) => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'stack';
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { saveEntryLanguage, state } from '../state.js';
|
||||
|
||||
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 }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack language-screen';
|
||||
const returnPage = resolveReturnPage();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Язык',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
title: 'Язык / Language',
|
||||
leftAction: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'card stack language-choice-card';
|
||||
card.innerHTML = `
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
||||
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||
<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');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
||||
const selected = card.querySelector('input[name="language"]:checked');
|
||||
if (selected) {
|
||||
state.entrySettings.language = selected.value;
|
||||
}
|
||||
navigate('settings-view');
|
||||
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const language = String(button.dataset.language || 'ru');
|
||||
saveEntryLanguage(language);
|
||||
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||
const selected = item === button;
|
||||
item.classList.toggle('is-selected', selected);
|
||||
item.setAttribute('aria-checked', String(selected));
|
||||
});
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
});
|
||||
|
||||
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
screen.append(card, actions);
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
||||
|
||||
export function render({ navigate }) {
|
||||
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 countdownTimer = 0;
|
||||
let activePairingId = '';
|
||||
@@ -70,16 +69,16 @@ export function render({ navigate }) {
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти через другое устройство',
|
||||
leftAction: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } },
|
||||
}),
|
||||
);
|
||||
|
||||
const panel = document.createElement('section');
|
||||
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');
|
||||
formCard.className = 'card stack login-device-preparation';
|
||||
@@ -183,7 +182,7 @@ export function render({ navigate }) {
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -216,7 +215,7 @@ export function render({ navigate }) {
|
||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||
};
|
||||
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -339,7 +338,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
@@ -172,14 +174,7 @@ function latestLocalDialogMessage(peerLogin) {
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
const value = Number(ts || 0);
|
||||
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));
|
||||
return formatRelativeTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -200,7 +195,7 @@ export function render({ navigate, chrome }) {
|
||||
<div class="dm-head-brand">
|
||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||
</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">
|
||||
<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>
|
||||
@@ -220,6 +215,27 @@ export function render({ navigate, chrome }) {
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
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 menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// 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);
|
||||
});
|
||||
|
||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
const rows = Array.from(byPeer.values())
|
||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||
.sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
@@ -458,12 +476,15 @@ function renderRow(item) {
|
||||
}
|
||||
}
|
||||
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
chatFilterMenu.destroy();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -110,84 +111,19 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
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();
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: profileMenuButton,
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
items: [
|
||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
],
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -382,5 +318,6 @@ export function render({ navigate, chrome }) {
|
||||
updateAvatarUi();
|
||||
refreshProfileSnapshot();
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
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',
|
||||
shortTitle: 'ESP32',
|
||||
|
||||
@@ -86,7 +86,7 @@ export function render({ navigate }) {
|
||||
|
||||
const deviceRow = createKeyInfo(
|
||||
deviceToggle,
|
||||
'Ключ device (всегда)',
|
||||
'Client key (всегда)',
|
||||
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
|
||||
);
|
||||
|
||||
|
||||
@@ -40,28 +40,28 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" 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-device">Устройства</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Сервер доступа</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||
</span>
|
||||
</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;">
|
||||
<strong>Серверы блокчейнов</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</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;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" 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-language">Язык / Language</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
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-blockchain-servers').addEventListener('click', () => navigate('server-settings-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');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
@@ -117,7 +120,7 @@ export function render({ navigate }) {
|
||||
const developerCard = document.createElement('div');
|
||||
developerCard.className = 'card stack';
|
||||
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'));
|
||||
|
||||
|
||||
@@ -36,11 +36,14 @@ export function render({ navigate }) {
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const guestViewButton = document.createElement('button');
|
||||
guestViewButton.className = 'shine-btn shine-btn--view';
|
||||
guestViewButton.type = 'button';
|
||||
guestViewButton.textContent = 'Только просмотр';
|
||||
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
||||
const languageButton = document.createElement('button');
|
||||
languageButton.className = 'shine-btn shine-btn--view';
|
||||
languageButton.type = 'button';
|
||||
languageButton.textContent = 'Язык / Language';
|
||||
languageButton.addEventListener('click', () => {
|
||||
sessionStorage.setItem('shine-language-return-page', 'start-view');
|
||||
navigate('language-view');
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'shine-btn shine-btn--settings';
|
||||
@@ -48,7 +51,7 @@ export function render({ navigate }) {
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
||||
actions.append(loginButton, registerButton, languageButton, settingsButton);
|
||||
screen.append(logoWrap, title, actions);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const rtf = (() => {
|
||||
try {
|
||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
|
||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'always' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -33,41 +33,30 @@ export function formatRelativeTime(timestampMs) {
|
||||
if (!ts) return '—';
|
||||
|
||||
const now = Date.now();
|
||||
const dt = new Date(ts);
|
||||
const nowDt = new Date(now);
|
||||
const diffSeconds = (ts - now) / 1000;
|
||||
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
|
||||
const ageHours = ageSeconds / 3600;
|
||||
const ageSeconds = Math.max(0, (now - ts) / 1000);
|
||||
const ageDays = ageSeconds / 86400;
|
||||
|
||||
if (ageHours <= 10) {
|
||||
if (ageDays < 7) {
|
||||
const [unit, value] = pickUnit(diffSeconds);
|
||||
if (rtf) return rtf.format(value, unit);
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const suffix = value <= 0 ? 'назад' : 'через';
|
||||
const labels = {
|
||||
second: 'сек',
|
||||
minute: 'мин',
|
||||
hour: 'ч',
|
||||
day: 'д',
|
||||
month: 'мес',
|
||||
year: 'г',
|
||||
};
|
||||
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
|
||||
const labels = { second: 'сек', minute: 'мин', hour: 'ч', day: 'д' };
|
||||
return `${absValue} ${labels[unit] || ''} ${suffix}`.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const dt = new Date(ts);
|
||||
const nowDt = new Date(now);
|
||||
const formatter = new Intl.DateTimeFormat('ru-RU', {
|
||||
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();
|
||||
if (dt.getFullYear() === nowDt.getFullYear()) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
}).format(dt).replace(',', ',');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
}).format(dt);
|
||||
}
|
||||
|
||||
function ensureToastHost() {
|
||||
|
||||
+10
-5
@@ -300,15 +300,15 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
keyStorage: {
|
||||
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||
clientKey: 'Ключ device хранится в зашифрованном виде',
|
||||
clientKey: 'Client key хранится в зашифрованном виде',
|
||||
saveRoot: true,
|
||||
saveBlockchain: true,
|
||||
saveDevice: true,
|
||||
saveClient: true,
|
||||
},
|
||||
deviceConnect: {
|
||||
root: true,
|
||||
blockchain: true,
|
||||
device: true,
|
||||
client: true,
|
||||
},
|
||||
authUi: {
|
||||
busy: false,
|
||||
@@ -848,6 +848,11 @@ export function checkServerAvailability(address) {
|
||||
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) {
|
||||
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()
|
||||
@@ -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) {
|
||||
await tryCloseCurrentSessionOnServer();
|
||||
}
|
||||
@@ -1031,7 +1036,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
||||
} catch {
|
||||
// ignore reconnect errors on sign out
|
||||
}
|
||||
if (onSessionReset) {
|
||||
if (notifySessionReset && onSessionReset) {
|
||||
onSessionReset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,3 +316,62 @@
|
||||
:root button.dm-message-action-btn--danger {
|
||||
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 {
|
||||
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