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