Compare commits

...
2 Commits
30 changed files with 1097 additions and 274 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.7.4
client.version=1.7.6
server.version=1.6.2
@@ -197,3 +197,9 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
- read-receipts;
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
- общий постоянный 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` из серверной сводки.
Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются.
## Примечание UI списка чатов (2026-08-28)
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
+2 -2
View File
@@ -1,4 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="24.5" r="10.5" stroke="#F2F5FB" stroke-width="3"/>
<path d="M15.5 48C17.8 38.9 24.2 34.5 32 34.5C39.8 34.5 46.2 38.9 48.5 48" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round"/>
<circle cx="32" cy="23.5" r="10.5" stroke="#65B7FF" stroke-width="3.2"/>
<path d="M14.5 49C17.1 39.2 23.9 34.5 32 34.5C40.1 34.5 46.9 39.2 49.5 49" stroke="#65B7FF" stroke-width="3.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 316 B

+3 -3
View File
@@ -1,4 +1,4 @@
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Z" stroke="#F2F5FB" stroke-width="1.65"/>
<path d="M19.15 13.15c.04-.38.04-.77 0-1.15l1.55-1.2-1.75-3.02-1.82.74a7.4 7.4 0 0 0-1-.58L15.86 6h-3.5l-.27 1.94c-.35.16-.68.35-1 .58l-1.82-.74-1.75 3.02L9.07 12a6 6 0 0 0 0 1.15l-1.55 1.2 1.75 3.03 1.82-.75c.32.23.65.42 1 .58l.27 1.94h3.5l.27-1.94c.35-.16.68-.35 1-.58l1.82.75 1.75-3.03-1.55-1.2Z" stroke="#F2F5FB" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M27.1 11.5h9.8l1.4 6.1c1.5.6 2.9 1.4 4.2 2.4l6-1.9 4.9 8.5-4.6 4.2c.1.8.2 1.6.2 2.4s-.1 1.6-.2 2.4l4.6 4.2-4.9 8.5-6-1.9c-1.3 1-2.7 1.8-4.2 2.4l-1.4 6.1h-9.8l-1.4-6.1c-1.5-.6-2.9-1.4-4.2-2.4l-6 1.9-4.9-8.5 4.6-4.2a16 16 0 0 1 0-4.8l-4.6-4.2 4.9-8.5 6 1.9c1.3-1 2.7-1.8 4.2-2.4l1.4-6.1Z" stroke="#65B7FF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="33.2" r="8.2" stroke="#65B7FF" stroke-width="3.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 558 B

+3 -4
View File
@@ -1,6 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 24.5C13.5 20.9101 16.4101 18 20 18H41.5C45.0899 18 48 20.9101 48 24.5V26H23.5C21.0147 26 19 28.0147 19 30.5C19 32.9853 21.0147 35 23.5 35H48V39.5C48 43.0899 45.0899 46 41.5 46H20C16.4101 46 13.5 43.0899 13.5 39.5V24.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<path d="M19 30.5C19 28.0147 21.0147 26 23.5 26H50.5V35H23.5C21.0147 35 19 32.9853 19 30.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<circle cx="42.5" cy="30.5" r="2.4" fill="#F2F5FB"/>
<path d="M18.5 22H38" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
<path d="M13 23.5C13 19.9 15.9 17 19.5 17H42C45.6 17 48.5 19.9 48.5 23.5V27H24C21.2 27 19 29.2 19 32C19 34.8 21.2 37 24 37H48.5V40.5C48.5 44.1 45.6 47 42 47H19.5C15.9 47 13 44.1 13 40.5V23.5Z" stroke="#65B7FF" stroke-width="3.2" stroke-linejoin="round"/>
<path d="M19 32C19 29.2 21.2 27 24 27H51V37H24C21.2 37 19 34.8 19 32Z" stroke="#65B7FF" stroke-width="3.2" stroke-linejoin="round"/>
<circle cx="42.5" cy="32" r="2.5" fill="#65B7FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 548 B

+21 -2
View File
@@ -229,9 +229,17 @@ window.__SHINE_BUILD_HASH__ = '20260826145335';
document.getElementById(menuId)?.remove();
} catch {}
};
const dismissError = () => {
removeMenu();
try {
document.getElementById('boot-error-overlay')?.remove();
setState(null);
} catch {}
};
const openMenu = () => {
try {
removeMenu();
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: 'boot-error' } }));
const report = buildReport();
const root = document.getElementById('modal-root') || document.body;
const shell = document.createElement('div');
@@ -246,7 +254,7 @@ window.__SHINE_BUILD_HASH__ = '20260826145335';
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
<button type="button" class="secondary-btn" data-action="dismiss">Закрыть</button>
</div>
</div>
`;
@@ -308,7 +316,18 @@ window.__SHINE_BUILD_HASH__ = '20260826145335';
close();
});
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
shell.querySelector('[data-action="dismiss"]')?.addEventListener('click', () => {
close();
try {
document.getElementById('boot-error-overlay')?.remove();
setState(null);
} catch {}
});
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === 'boot-error') return;
close();
};
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen, { once: true });
dialog?.focus?.();
window.addEventListener('keydown', onKeyDown);
} catch (error) {
+26 -2
View File
@@ -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) {
+139
View File
@@ -0,0 +1,139 @@
export function createDropdownMenu({
anchorEl,
items = [],
className = '',
minWidth = 210,
offset = 7,
align = 'right',
leftShift = 0,
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 baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
const desiredLeft = baseLeft - Number(leftShift || 0);
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;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
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 onPeerDropdownOpen = (event) => {
if (!portal || event?.detail?.owner === anchorEl) 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);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
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);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', position);
window.removeEventListener('scroll', position, true);
},
};
}
+4
View File
@@ -83,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) {
</span>
</span>
`;
} else if (isNetwork) {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
btn.setAttribute('aria-label', item.label);
btn.title = item.label;
} else {
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="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());
+37 -13
View File
@@ -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: 'Каналы' };
@@ -965,6 +966,7 @@ function openTopChannelsMenu({
onFindChannel,
}) {
closeTopChannelsMenu(listState);
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
@@ -974,7 +976,9 @@ function openTopChannelsMenu({
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
let top = rect.bottom + 8;
const titleAnchor = document.querySelector('.channels-filter-title');
const titleRect = titleAnchor?.getBoundingClientRect?.();
let top = (titleRect?.bottom || rect.bottom) + 7;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
@@ -992,10 +996,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) => {
@@ -1025,13 +1025,19 @@ function openTopChannelsMenu({
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
};
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === anchorEl) return;
closeTopChannelsMenu(listState);
};
const onWindowResize = () => closeTopChannelsMenu(listState);
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
listState.topMenuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
};
}
@@ -1191,16 +1197,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 +1251,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 +1258,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 +1370,21 @@ 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',
leftShift: 72,
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 +1451,7 @@ export function render({ navigate, route, chrome }) {
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
};
+8 -8
View File
@@ -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 = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
+9 -9
View File
@@ -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');
+108 -26
View File
@@ -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' });
+1 -1
View File
@@ -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 = 'Завершить сеанс';
+6 -4
View File
@@ -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 () => {
+5 -23
View File
@@ -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';
@@ -68,7 +50,7 @@ export function render({ navigate }) {
controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn server-check-btn';
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
@@ -150,7 +132,7 @@ export function render({ navigate }) {
if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button');
localDemoButton.className = 'ghost-btn preauth-local-demo-btn';
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
@@ -161,7 +143,7 @@ export function render({ navigate }) {
}
const serverUiButton = document.createElement('button');
serverUiButton.className = 'ghost-btn';
serverUiButton.className = 'shine-btn shine-btn--settings';
serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => {
@@ -170,13 +152,13 @@ export function render({ navigate }) {
});
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.className = 'shine-btn shine-btn--settings';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button');
saveButton.className = 'primary-btn';
saveButton.className = 'shine-btn shine-btn--primary';
saveButton.type = 'button';
saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', async () => {
+49 -22
View File
@@ -1,43 +1,70 @@
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();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
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-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
<span class="language-segmented-thumb" aria-hidden="true"></span>
</div>
<div class="language-choice-actions">
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</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>
`;
const syncSelection = () => {
card.dataset.language = pendingLanguage;
card.querySelectorAll('[data-language]').forEach((item) => {
const selected = item.dataset.language === pendingLanguage;
item.classList.toggle('is-selected', selected);
item.setAttribute('aria-checked', String(selected));
});
};
syncSelection();
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', () => {
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
syncSelection();
});
});
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
});
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
saveEntryLanguage(pendingLanguage);
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;
}
+12 -13
View File
@@ -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
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
+46 -16
View File
@@ -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,28 @@ 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',
leftShift: 72,
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
@@ -243,11 +260,13 @@ export function render({ navigate, chrome }) {
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
const titleRect = filterTitle?.getBoundingClientRect?.();
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
@@ -288,6 +307,10 @@ export function render({ navigate, chrome }) {
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onPeerDropdownOpen = (event) => {
if (!menuPortal || event?.detail?.owner === menuButton) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
@@ -296,6 +319,7 @@ export function render({ navigate, chrome }) {
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
@@ -395,12 +419,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,14 +484,18 @@ 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);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
+11 -74
View File
@@ -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',
+1 -1
View File
@@ -86,7 +86,7 @@ export function render({ navigate }) {
const deviceRow = createKeyInfo(
deviceToggle,
'Ключ device (всегда)',
'Client key (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
);
+12 -9
View File
@@ -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'));
+9 -6
View File
@@ -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;
}
+16 -27
View File
@@ -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
View File
@@ -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();
}
}
+59
View File
@@ -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;
}
+477
View File
@@ -10008,3 +10008,480 @@ 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: 27px;
height: 27px;
min-width: 27px;
flex-basis: 27px;
filter: drop-shadow(0 0 6px rgba(101, 183, 255, 0.36));
}
.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% - 2px);
grid-template-columns: 46px minmax(0, 1fr);
gap: 8px;
min-height: 44px;
margin: 1px 1px;
padding: 3px 6px 3px 3px;
border-radius: 14px;
}
.channels-screen--list .channel-row .avatar {
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
}
.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;
}
/* ===== UX refinements 2026-08-29 ===== */
/* Start: larger breathing room between brand and actions. */
.auth-screen--welcome .auth-brand {
margin-bottom: clamp(34px, 6vh, 58px) !important;
}
.auth-screen--welcome .shine-actions {
margin-top: 0;
}
/* Language selector: actual two-position switch with explicit confirmation. */
.language-segmented-control {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
padding: 4px;
min-height: 58px;
border: 1px solid rgba(190, 210, 238, .18);
border-radius: 18px;
background: rgba(12, 22, 40, .72);
overflow: hidden;
}
.language-segmented-thumb {
position: absolute;
z-index: 0;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
border: 1px solid rgba(99, 190, 255, .52);
border-radius: 14px;
background: linear-gradient(180deg, rgba(76, 166, 255, .28), rgba(30, 105, 204, .20));
box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 0 18px rgba(63,160,255,.14);
transition: transform 180ms ease;
}
.language-choice-card[data-language="en"] .language-segmented-thumb { transform: translateX(100%); }
.language-segment {
position: relative;
z-index: 1;
min-height: 50px;
padding: 0 12px;
border: 0 !important;
background: transparent !important;
color: rgba(231,239,255,.64);
font-size: 16px;
font-weight: 650;
}
.language-segment.is-selected { color: #eef8ff; }
.language-choice-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
}
.language-choice-actions .shine-btn { height: 50px; }
/* Entry settings actions use the existing Start button visual language without huge width side effects. */
.screen-content.preauth-flow .server-check-btn.shine-btn {
width: auto;
height: 44px;
min-height: 44px;
padding-inline: 18px;
border-radius: 14px;
font-size: 15px;
}
.screen-content.preauth-flow .auth-footer-actions .shine-btn {
width: 100%;
}
/* Channels: much wider, substantially shorter rows. */
.channels-screen--list .channels-list-content,
.channels-screen--list .channels-groups,
.channels-screen--list .stack:has(> .channel-row) {
width: calc(100% + 16px) !important;
margin-left: -8px !important;
margin-right: -8px !important;
}
.channels-screen--list .channel-row {
width: 100% !important;
min-height: 54px !important;
margin: 1px 0 !important;
padding: 5px 6px 5px 4px !important;
grid-template-columns: 50px minmax(0, 1fr) !important;
gap: 8px !important;
border-radius: 15px !important;
}
.channels-screen--list .channel-row .avatar {
width: 48px !important;
height: 48px !important;
min-width: 48px !important;
min-height: 48px !important;
}
.channels-screen--list .channel-row-main {
gap: 0 !important;
padding-right: 3px !important;
align-self: center;
overflow: hidden;
}
.channels-screen--list .channel-row-title,
.channels-screen--list .channel-row-technical,
.channels-screen--list .channel-row-description,
.channels-screen--list .channel-row-owner {
margin-block: 0 !important;
line-height: 1.12 !important;
}
.channels-screen--list .channel-row-preview-line {
display: flex !important;
align-items: baseline !important;
gap: 7px !important;
min-width: 0 !important;
width: 100% !important;
line-height: 1.15 !important;
}
.channels-screen--list .channel-row-preview-line .channel-row-message {
flex: 1 1 auto !important;
min-width: 0 !important;
max-width: 100% !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
line-height: 1.15 !important;
}
.channels-screen--list .channel-row-preview-line .channel-row-time {
flex: 0 0 auto !important;
position: static !important;
align-self: baseline !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1.15 !important;
transform: none !important;
white-space: nowrap !important;
}
.channels-screen--list .channel-row-controls { top: 3px !important; right: 5px !important; }
/* Toolbar: reserve enough vertical room for the Connections label and safe area. */
:root { --toolbar-height: 88px; }
.toolbar-slot {
min-height: calc(88px + env(safe-area-inset-bottom)) !important;
overflow: visible !important;
padding-bottom: env(safe-area-inset-bottom) !important;
}
.toolbar {
min-height: 82px !important;
overflow: visible !important;
padding-bottom: 6px !important;
align-items: end;
}
.toolbar-btn-network {
min-height: 72px !important;
align-content: end !important;
overflow: visible !important;
padding-bottom: 2px !important;
}
.toolbar-btn-network .toolbar-icon-img {
transform: translateY(-4px) !important;
}
.toolbar-btn-network > span:last-child {
display: block !important;
transform: translateY(-2px) !important;
line-height: 1.05 !important;
padding-bottom: 1px !important;
}
.channels-screen--list .channel-row-technical,
.channels-screen--list .channel-row-description,
.channels-screen--list .channel-row-owner {
display: none !important;
}