From b47440dd7250cc6c5b33ffa4819e57c34d58385534e6a29fb8661e19296f51df Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 29 Aug 2026 15:12:13 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20UI=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- docs/Personal_Messages/Протокол_DM_v1.md | 6 + docs/Personal_Messages/Формат_DM_v1.md | 3 + shine-UI/js/app.js | 28 +- shine-UI/js/components/dropdown-menu.js | 130 ++++++++ shine-UI/js/components/toolbar.js | 4 + shine-UI/js/pages/arweave-uploads-view.js | 6 +- shine-UI/js/pages/channels-list.js | 38 ++- shine-UI/js/pages/connect-device-view.js | 16 +- shine-UI/js/pages/developer-settings-view.js | 18 +- shine-UI/js/pages/device-pairing-view.js | 134 ++++++-- shine-UI/js/pages/device-session-view.js | 2 +- shine-UI/js/pages/device-view.js | 10 +- shine-UI/js/pages/entry-settings-view.js | 18 -- shine-UI/js/pages/language-view.js | 61 ++-- shine-UI/js/pages/login-other-device-view.js | 25 +- shine-UI/js/pages/messages-list.js | 51 +++- shine-UI/js/pages/profile-view.js | 85 +----- shine-UI/js/pages/registration-faq-view.js | 10 + shine-UI/js/pages/registration-keys-view.js | 2 +- shine-UI/js/pages/settings-view.js | 21 +- shine-UI/js/pages/start-view.js | 15 +- shine-UI/js/services/channels-ux.js | 43 +-- shine-UI/js/state.js | 15 +- shine-UI/styles/buttons-white.css | 59 ++++ shine-UI/styles/components.css | 306 +++++++++++++++++++ 26 files changed, 851 insertions(+), 257 deletions(-) create mode 100644 shine-UI/js/components/dropdown-menu.js diff --git a/VERSION.properties b/VERSION.properties index 638df9af..fada4d9b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.7.4 +client.version=1.7.5 server.version=1.6.2 diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index dca0965f..0a22fe0f 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -197,3 +197,9 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает - read-receipts; - синхронизацию пользовательских блокчейнов через server-PDA sync_servers; - общий постоянный WSS ServerConnectionPool. + +## UI списка личных чатов — фильтры и отображение времени (2026-08-28) +- Заголовок «Чаты» является переключателем фильтра списка. +- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые». +- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются. +- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 871ab0a0..5c870d04 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -353,3 +353,6 @@ ReadReceiptBody_v1_0 Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки. Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются. + +## Примечание UI списка чатов (2026-08-28) +Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dce688e9..eb3f9eee 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -211,9 +211,30 @@ const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([ 'settings-view', ]); +const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([ + 'access-servers-view', + 'server-settings-view', + 'arweave-uploads-view', + 'developer-settings-view', + 'trusted-device-login-settings-view', + 'device-view', + 'device-session-view', + 'connect-device-view', + 'device-pairing-view', + 'device-qr-view', + 'device-camera-view', + 'show-keys-view', + 'remote-addblock-session-view', + 'app-log-view', + 'pwa-diagnostics-view', + 'solana-users-init-view', + 'solana-rpc-check-view', +]); + const GUEST_ALLOWED_PAGES = new Set([ 'start-view', 'entry-settings-view', + 'language-view', 'network-view', 'channels-list', 'channel-view', @@ -1178,7 +1199,8 @@ function renderApp() { } const page = routes[pageId] || routes['start-view']; - const showAppChrome = page.pageMeta?.showAppChrome !== false; + const showAppChrome = page.pageMeta?.showAppChrome !== false + && !(pageId === 'language-view' && !state.session.isAuthorized); if (typeof currentCleanup === 'function') { currentCleanup(); currentCleanup = null; @@ -1208,6 +1230,7 @@ function renderApp() { screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId)); + screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId)); toolbarEl.innerHTML = ''; if (showAppChrome) { @@ -1225,7 +1248,8 @@ function refreshToolbarOnly() { const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); const page = routes[pageId] || routes['start-view']; - const showAppChrome = page.pageMeta?.showAppChrome !== false; + const showAppChrome = page.pageMeta?.showAppChrome !== false + && !(pageId === 'language-view' && !state.session.isAuthorized); toolbarEl.innerHTML = ''; if (showAppChrome) { diff --git a/shine-UI/js/components/dropdown-menu.js b/shine-UI/js/components/dropdown-menu.js new file mode 100644 index 00000000..2131101e --- /dev/null +++ b/shine-UI/js/components/dropdown-menu.js @@ -0,0 +1,130 @@ +export function createDropdownMenu({ + anchorEl, + items = [], + className = '', + minWidth = 210, + offset = 7, + align = 'right', + onOpen = null, + onClose = null, +} = {}) { + let portal = null; + + const close = () => { + if (!portal) return; + portal.remove(); + portal = null; + anchorEl?.setAttribute('aria-expanded', 'false'); + onClose?.(); + }; + + const position = () => { + if (!portal || !anchorEl) return; + const rect = anchorEl.getBoundingClientRect(); + const margin = 10; + const menuWidth = portal.offsetWidth || minWidth; + const desiredLeft = align === 'left' ? rect.left : rect.right - menuWidth; + const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft)); + let top = rect.bottom + offset; + const menuHeight = portal.offsetHeight || 180; + if (top + menuHeight > window.innerHeight - margin) { + top = Math.max(margin, rect.top - menuHeight - offset); + } + portal.style.left = `${Math.round(left)}px`; + portal.style.top = `${Math.round(top)}px`; + }; + + const open = () => { + if (!anchorEl || portal) return; + const menu = document.createElement('div'); + menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim(); + menu.setAttribute('role', 'menu'); + menu.style.minWidth = `${minWidth}px`; + + items.forEach((item) => { + if (item?.divider) { + const divider = document.createElement('div'); + divider.className = 'channel-menu-divider'; + menu.append(divider); + return; + } + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`; + btn.setAttribute('role', 'menuitem'); + if (item?.iconHtml) { + const icon = document.createElement('span'); + icon.className = 'shared-dropdown-menu__icon'; + icon.innerHTML = item.iconHtml; + btn.append(icon); + } else if (item?.iconSrc) { + const icon = document.createElement('img'); + icon.src = item.iconSrc; + icon.alt = ''; + icon.setAttribute('aria-hidden', 'true'); + btn.append(icon); + } + const label = document.createElement('span'); + label.textContent = String(item?.label || ''); + btn.append(label); + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + close(); + item?.action?.(); + }); + menu.append(btn); + }); + + menu.addEventListener('click', (event) => event.stopPropagation()); + document.body.append(menu); + portal = menu; + anchorEl.setAttribute('aria-expanded', 'true'); + onOpen?.(); + position(); + }; + + const toggle = () => { + if (portal) close(); + else open(); + }; + + const onAnchorClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + toggle(); + }; + const onOutsideClick = (event) => { + if (!portal) return; + if (portal.contains(event.target) || anchorEl?.contains(event.target)) return; + close(); + }; + const onKeydown = (event) => { + if (event.key !== 'Escape' || !portal) return; + close(); + anchorEl?.focus(); + }; + + anchorEl?.setAttribute('aria-haspopup', 'menu'); + anchorEl?.setAttribute('aria-expanded', 'false'); + anchorEl?.addEventListener('click', onAnchorClick); + document.addEventListener('click', onOutsideClick); + document.addEventListener('keydown', onKeydown); + window.addEventListener('resize', position, { passive: true }); + window.addEventListener('scroll', position, { passive: true, capture: true }); + + return { + open, + close, + toggle, + position, + destroy() { + close(); + anchorEl?.removeEventListener('click', onAnchorClick); + document.removeEventListener('click', onOutsideClick); + document.removeEventListener('keydown', onKeydown); + window.removeEventListener('resize', position); + window.removeEventListener('scroll', position, true); + }, + }; +} diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 509f199c..6b800f2b 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -83,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) { `; + } else if (isNetwork) { + btn.innerHTML = iconHtml(item); + btn.setAttribute('aria-label', item.label); + btn.title = item.label; } else { btn.innerHTML = `${iconHtml(item)}${item.label}`; } diff --git a/shine-UI/js/pages/arweave-uploads-view.js b/shine-UI/js/pages/arweave-uploads-view.js index 30dbf614..1e919126 100644 --- a/shine-UI/js/pages/arweave-uploads-view.js +++ b/shine-UI/js/pages/arweave-uploads-view.js @@ -76,9 +76,9 @@ export function render({ navigate }) { `; controls.querySelector('[data-action="menu"]')?.append(createOverflowDots()); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index effaf71d..09815df2 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -15,6 +15,7 @@ import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelShortRoute } from '../services/shine-routes.js'; import { renderAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createDropdownMenu } from '../components/dropdown-menu.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; @@ -992,10 +993,6 @@ function openTopChannelsMenu({ const items = [ { label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() }, { label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') }, - { divider: true }, - { label: 'Все каналы', icon: 'all', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) }, - { label: 'Мои каналы', icon: 'mine', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) }, - { label: 'Подписки', icon: 'following', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) }, ]; items.forEach((item) => { @@ -1191,16 +1188,24 @@ function renderChannelMain(channel) { main.append(desc); } + const previewLine = document.createElement('div'); + previewLine.className = 'channel-row-preview-line'; + const preview = document.createElement('p'); preview.className = 'channel-row-message'; preview.textContent = channel.messagePreview || 'Ждем ваших начинаний'; + const time = document.createElement('span'); + time.className = 'channel-row-time'; + time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; + previewLine.append(preview, time); + const meta = document.createElement('p'); meta.className = 'channel-row-owner channel-counter-meta'; meta.textContent = `Сообщений: ${channel.messagesCount || 0}`; main.prepend(title, technical); - main.append(preview, meta); + main.append(previewLine, meta); return main; } @@ -1237,10 +1242,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed const controls = document.createElement('div'); controls.className = 'channel-row-controls'; - const time = document.createElement('span'); - time.className = 'channel-row-time'; - time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; - const count = document.createElement('span'); count.className = 'unread channel-row-count'; const unreadCount = Number(channel.unreadCount || 0); @@ -1248,7 +1249,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed count.textContent = unreadCount > 0 ? String(unreadCount) : ''; count.classList.toggle('is-empty', unreadCount <= 0); - controls.append(time, count); + controls.append(count); row.append(avatar, main, controls); row.addEventListener('click', () => { @@ -1360,8 +1361,20 @@ export function render({ navigate, route, chrome }) { const topBarLeft = document.createElement('div'); topBarLeft.className = 'channels-top-left'; - const topTitle = document.createElement('strong'); - topTitle.className = 'channels-top-title'; + const topTitle = document.createElement('button'); + topTitle.type = 'button'; + topTitle.className = 'channels-top-title channels-filter-title'; + + const channelsFilterMenu = createDropdownMenu({ + anchorEl: topTitle, + align: 'left', + minWidth: 220, + items: [ + { label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) }, + { label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) }, + { label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) }, + ], + }); const topBarRight = document.createElement('div'); topBarRight.className = 'channels-top-right'; @@ -1428,6 +1441,7 @@ export function render({ navigate, route, chrome }) { screen.cleanup = () => { closeChannelMenu(listState); closeTopChannelsMenu(listState); + channelsFilterMenu.destroy(); appScreen?.classList.remove('channels-scroll-clean'); }; diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index b869c1ba..fe69b9e9 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -48,7 +48,7 @@ export function render({ navigate }) { state.deviceConnect.blockchain = blockchainToggle.checked; }); deviceToggle.addEventListener('change', () => { - state.deviceConnect.device = true; + state.deviceConnect.client = true; deviceToggle.checked = true; }); @@ -100,22 +100,22 @@ export function render({ navigate }) { const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const hasRoot = Boolean(savedKeys.rootKey); const hasBlockchain = Boolean(savedKeys.blockchainKey); - const hasDevice = Boolean(savedKeys.clientKey); + const hasClient = Boolean(savedKeys.clientKey); rootToggle.disabled = !hasRoot; blockchainToggle.disabled = !hasBlockchain; deviceToggle.disabled = true; state.deviceConnect.root = hasRoot && rootToggle.checked; state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked; - state.deviceConnect.device = hasDevice; + state.deviceConnect.client = hasClient; rootToggle.checked = state.deviceConnect.root; blockchainToggle.checked = state.deviceConnect.blockchain; - deviceToggle.checked = hasDevice; - openQrBtn.disabled = !hasDevice; - openPairBtn.disabled = !hasDevice; + deviceToggle.checked = hasClient; + openQrBtn.disabled = !hasClient; + openPairBtn.disabled = !hasClient; const available = [ - hasDevice ? 'device' : '', + hasClient ? 'client' : '', hasBlockchain ? 'blockchain' : '', hasRoot ? 'root' : '', ].filter(Boolean); @@ -128,7 +128,7 @@ export function render({ navigate }) { deviceToggle.checked = false; state.deviceConnect.root = false; state.deviceConnect.blockchain = false; - state.deviceConnect.device = false; + state.deviceConnect.client = false; openQrBtn.disabled = true; openPairBtn.disabled = true; statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.'; diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js index a404bbbe..127c7672 100644 --- a/shine-UI/js/pages/developer-settings-view.js +++ b/shine-UI/js/pages/developer-settings-view.js @@ -260,15 +260,15 @@ export function render({ navigate }) { const card = document.createElement('div'); card.className = 'card stack settings-developer-card'; card.innerHTML = ` - - - - - - - - - + + + + + + + + + `; const appLogBtn = card.querySelector('#settings-app-log'); diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 9972a608..aeae922a 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -36,22 +36,20 @@ function pairingSessionKindLabel(sessionType) { return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session'; } -function buildTransferKeys(savedKeys, { withExtras = false }) { +function buildTransferKeys(savedKeys, selection = {}) { const keys = { - clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(), + clientKey: String(savedKeys?.clientKey || '').trim(), blockchainKey: '', rootKey: '', }; if (!keys.clientKey) { throw new Error('На этом устройстве нет сохранённого client key для передачи.'); } - if (withExtras) { - if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) { - keys.blockchainKey = String(savedKeys.blockchainKey || '').trim(); - } - if (state.deviceConnect.root && savedKeys?.rootKey) { - keys.rootKey = String(savedKeys.rootKey || '').trim(); - } + if (selection.blockchain && savedKeys?.blockchainKey) { + keys.blockchainKey = String(savedKeys.blockchainKey || '').trim(); + } + if (selection.root && savedKeys?.rootKey) { + keys.rootKey = String(savedKeys.rootKey || '').trim(); } return keys; } @@ -73,10 +71,9 @@ function requestCardHtml(request) { Истекает: ${expiresText} -
- - ${sessionOnly ? '' : ''} - +
+ +
`; @@ -179,6 +176,7 @@ export function render({ navigate }) { let settingsBusy = false; let pairingPasswordConfigured = false; let dialogMode = ''; + let pendingTransferRequest = null; screen.append( renderHeader({ @@ -187,6 +185,38 @@ export function render({ navigate }) { }), ); + const transferDialog = document.createElement('div'); + transferDialog.className = 'pairing-transfer-dialog'; + transferDialog.hidden = true; + transferDialog.innerHTML = ` +
+ + `; + screen.append(transferDialog); + const settingsCard = document.createElement('div'); settingsCard.className = 'card stack'; const passwordIcons = makePasswordToggleIcons(); @@ -432,11 +462,11 @@ export function render({ navigate }) { const loadSavedKeys = async () => { savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const available = []; - if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client'); - if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain'); - if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root'); - keySummaryEl.textContent = available.length - ? `При расширенном подключении будут переданы: ${available.join(', ')}.` + if (savedKeys?.clientKey) available.push('client'); + if (savedKeys?.blockchainKey) available.push('blockchain'); + if (savedKeys?.rootKey) available.push('root'); + keySummaryEl.textContent = available.length > 1 + ? `Доступны для подключения: ${available.join(', ')}.` : 'На этом устройстве доступен только client key.'; }; @@ -460,10 +490,10 @@ export function render({ navigate }) { refreshBtn.disabled = flag; }; - const approveRequest = async (request, mode) => { - const withExtras = mode === 'with-extras'; + const approveRequest = async (request, selection = {}) => { + const withExtras = !!selection.root || !!selection.blockchain; let payload; - if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) { + if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) { const delegatedSession = await authService.createDelegatedSessionWithClientKey({ login: state.session.login, clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(), @@ -477,7 +507,7 @@ export function render({ navigate }) { session: delegatedSession, }); } else { - const keys = buildTransferKeys(savedKeys, { withExtras }); + const keys = buildTransferKeys(savedKeys, selection); payload = buildSecretsPayload({ login: state.session.login, keys, @@ -486,7 +516,7 @@ export function render({ navigate }) { } const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload); await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload)); - const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET; + const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET; showToast( withExtras ? 'Ключи переданы на новое устройство' @@ -505,6 +535,53 @@ export function render({ navigate }) { await reloadRequests({ silent: true }); }; + const closeTransferDialog = () => { + pendingTransferRequest = null; + transferDialog.hidden = true; + }; + + const openTransferDialog = (request) => { + pendingTransferRequest = request; + const rootRow = transferDialog.querySelector('[data-transfer-key="root"]'); + const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]'); + const rootInput = transferDialog.querySelector('#pairing-transfer-root'); + const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain'); + const hasRoot = !!String(savedKeys?.rootKey || '').trim(); + const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim(); + rootRow.hidden = !hasRoot; + blockchainRow.hidden = !hasBlockchain; + rootInput.checked = hasRoot; + blockchainInput.checked = hasBlockchain; + transferDialog.hidden = false; + }; + + transferDialog.addEventListener('click', async (event) => { + const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null; + const action = String(target?.dataset?.transferAction || ''); + if (!action) return; + if (action === 'cancel') { + closeTransferDialog(); + return; + } + if (action !== 'confirm' || !pendingTransferRequest) return; + const request = pendingTransferRequest; + const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]'); + confirmBtn.disabled = true; + try { + await approveRequest(request, { + root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked, + blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked, + }); + closeTransferDialog(); + } catch (error) { + const message = toUserMessage(error, 'Не удалось подключить устройство.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + confirmBtn.disabled = false; + } + }); + passwordDialog.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; @@ -609,9 +686,14 @@ export function render({ navigate }) { buttons.forEach((btn) => { btn.disabled = true; }); try { if (action === 'approve-device') { - await approveRequest(request, 'device-only'); - } else if (action === 'approve-full') { - await approveRequest(request, 'with-extras'); + const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET; + const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim(); + if (!isWallet && hasExtras) { + openTransferDialog(request); + buttons.forEach((btn) => { btn.disabled = false; }); + return; + } + await approveRequest(request, {}); } else if (action === 'reject') { await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user')); showToast('Заявка отклонена', { kind: 'error' }); diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index 0cc5bf64..fd4a02df 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -68,7 +68,7 @@ export function render({ navigate, route }) { `; const actionBtn = document.createElement('button'); - actionBtn.className = 'text-btn'; + actionBtn.className = 'shine-btn shine-btn--settings'; actionBtn.type = 'button'; actionBtn.textContent = 'Завершить сеанс'; diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index b6ddbffa..92553639 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -54,11 +54,13 @@ export function render({ navigate }) { const actions = document.createElement('div'); actions.className = 'card stack'; actions.innerHTML = ` - - - + + + + `; + actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view')); actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view')); actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view')); @@ -109,7 +111,7 @@ export function render({ navigate }) { currentMenu.append(createSessionItem(current, true)); const endCurrentSessionBtn = document.createElement('button'); - endCurrentSessionBtn.className = 'text-btn'; + endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn'; endCurrentSessionBtn.type = 'button'; endCurrentSessionBtn.textContent = 'Завершить текущую сессию'; endCurrentSessionBtn.addEventListener('click', async () => { diff --git a/shine-UI/js/pages/entry-settings-view.js b/shine-UI/js/pages/entry-settings-view.js index 1af1437a..d37e60c7 100644 --- a/shine-UI/js/pages/entry-settings-view.js +++ b/shine-UI/js/pages/entry-settings-view.js @@ -33,24 +33,6 @@ export function render({ navigate }) { const body = document.createElement('div'); body.className = 'card stack'; - const languageLabel = document.createElement('label'); - languageLabel.className = 'stack'; - languageLabel.innerHTML = `Язык`; - - const languageSelect = document.createElement('select'); - languageSelect.className = 'select'; - languageSelect.innerHTML = ` - - - `; - languageSelect.value = draft.language; - languageSelect.addEventListener('change', () => { - draft.language = languageSelect.value; - }); - languageLabel.append(languageSelect); - - body.append(languageLabel); - SERVER_FIELDS.forEach((field) => { const block = document.createElement('div'); block.className = 'stack'; diff --git a/shine-UI/js/pages/language-view.js b/shine-UI/js/pages/language-view.js index 8ced0283..30da1a4d 100644 --- a/shine-UI/js/pages/language-view.js +++ b/shine-UI/js/pages/language-view.js @@ -1,43 +1,58 @@ import { renderHeader } from '../components/header.js'; -import { state } from '../state.js'; +import { saveEntryLanguage, state } from '../state.js'; export const pageMeta = { id: 'language-view', title: 'Язык' }; +function resolveReturnPage() { + const stored = String(sessionStorage.getItem('shine-language-return-page') || '').trim(); + return stored === 'start-view' ? 'start-view' : 'settings-view'; +} + export function render({ navigate }) { const screen = document.createElement('section'); - screen.className = 'stack'; + screen.className = 'stack language-screen'; + const returnPage = resolveReturnPage(); screen.append( renderHeader({ - title: 'Язык', - leftAction: { label: '←', onClick: () => navigate('settings-view') }, + title: 'Язык / Language', + leftAction: { label: '←', onClick: () => { + sessionStorage.removeItem('shine-language-return-page'); + navigate(returnPage); + } }, }), ); const card = document.createElement('div'); - card.className = 'card stack'; + card.className = 'card stack language-choice-card'; card.innerHTML = ` - - +

Выберите язык интерфейса

+
+ + +
`; - const actions = document.createElement('div'); - actions.className = 'auth-footer-actions'; - actions.innerHTML = ` - - - `; - - actions.querySelector('#language-ok').addEventListener('click', () => { - const selected = card.querySelector('input[name="language"]:checked'); - if (selected) { - state.entrySettings.language = selected.value; - } - navigate('settings-view'); + card.querySelectorAll('[data-language]').forEach((button) => { + button.addEventListener('click', () => { + const language = String(button.dataset.language || 'ru'); + saveEntryLanguage(language); + card.querySelectorAll('[data-language]').forEach((item) => { + const selected = item === button; + item.classList.toggle('is-selected', selected); + item.setAttribute('aria-checked', String(selected)); + }); + sessionStorage.removeItem('shine-language-return-page'); + navigate(returnPage); + }); }); - actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view')); - - screen.append(card, actions); + screen.append(card); return screen; } diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index ab9d0945..2eb2a313 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -1,4 +1,3 @@ -import { renderHeader } from '../components/header.js'; import { authService, authorizeSession, @@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi export function render({ navigate }) { const screen = document.createElement('section'); - screen.className = 'stack auth-screen auth-screen--lower'; + screen.className = 'stack auth-screen auth-screen--other-device'; let pollTimer = 0; let countdownTimer = 0; let activePairingId = ''; @@ -70,16 +69,16 @@ export function render({ navigate }) { clearAuthMessages(); - screen.append( - renderHeader({ - title: 'Войти через другое устройство', - leftAction: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } }, - }), - ); - const panel = document.createElement('section'); panel.className = 'login-panel login-panel--wide stack'; - panel.innerHTML = '

Войти через другое устройство

'; + panel.innerHTML = ` + + `; + + panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); }); const formCard = document.createElement('div'); formCard.className = 'card stack login-device-preparation'; @@ -183,7 +182,7 @@ export function render({ navigate }) { const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); - await terminateCurrentSession({ closeServerSession: true }); + await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); await clearStoredMessages().catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); @@ -216,7 +215,7 @@ export function render({ navigate }) { sessionType: Number(payloadSession?.sessionType || 50) || 50, }; - await terminateCurrentSession({ closeServerSession: true }); + await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); await clearStoredMessages().catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); @@ -339,7 +338,7 @@ export function render({ navigate }) { } shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || ''); - statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.'; + statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.'; onlineHintEl.textContent = payload?.trustedSessionOnline ? 'Доверенное устройство сейчас в сети и может сразу принять заявку.' : 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.'; diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index ba436d76..2772a3d0 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -9,9 +9,11 @@ import { } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; +import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; +import { formatRelativeTime } from '../services/channels-ux.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const PREVIEW_MAX_LEN = 200; @@ -172,14 +174,7 @@ function latestLocalDialogMessage(peerLogin) { } function formatChatRowTime(ts) { - const value = Number(ts || 0); - if (!Number.isFinite(value) || value <= 0) return ''; - return new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(value)); + return formatRelativeTime(ts); } function compareChatRows(a, b) { @@ -200,7 +195,7 @@ export function render({ navigate, chrome }) {
-

Чаты

+
`; - const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap'); const profileMenuButton = topbar.querySelector('.profile-head-menu-btn'); profileMenuButton?.append(createOverflowDots()); - let profileMenuPortal = null; - - const closeProfileMenu = () => { - profileMenuPortal?.remove(); - profileMenuPortal = null; - profileMenuButton?.setAttribute('aria-expanded', 'false'); - profileMenuWrap?.classList.remove('is-open'); - }; - - const positionProfileMenu = () => { - if (!profileMenuPortal || !profileMenuButton) return; - const rect = profileMenuButton.getBoundingClientRect(); - const margin = 10; - const menuWidth = profileMenuPortal.offsetWidth || 224; - const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth)); - profileMenuPortal.style.left = `${Math.round(left)}px`; - profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`; - }; - - const openProfileMenu = () => { - if (!profileMenuButton || profileMenuPortal) return; - const portal = document.createElement('div'); - portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu'; - portal.setAttribute('role', 'menu'); - portal.innerHTML = ` - - - - `; - - const goTo = (route) => { - closeProfileMenu(); - navigate(route); - }; - portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view')); - portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view')); - portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view')); - portal.addEventListener('click', (event) => event.stopPropagation()); - - document.body.append(portal); - profileMenuPortal = portal; - profileMenuButton.setAttribute('aria-expanded', 'true'); - profileMenuWrap?.classList.add('is-open'); - positionProfileMenu(); - }; - - profileMenuButton?.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - if (profileMenuPortal) closeProfileMenu(); - else openProfileMenu(); + const profileMenu = createDropdownMenu({ + anchorEl: profileMenuButton, + className: 'profile-head-menu', + minWidth: 230, + items: [ + { label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') }, + { label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') }, + { label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') }, + ], }); - document.addEventListener('click', (event) => { - if (!profileMenuPortal) return; - if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return; - closeProfileMenu(); - }); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || !profileMenuPortal) return; - closeProfileMenu(); - profileMenuButton?.focus(); - }); - window.addEventListener('resize', positionProfileMenu, { passive: true }); - window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true }); - chrome?.setTopbar(topbar); const card = document.createElement('div'); @@ -382,5 +318,6 @@ export function render({ navigate, chrome }) { updateAvatarUi(); refreshProfileSnapshot(); + screen.cleanup = () => profileMenu.destroy(); return screen; } diff --git a/shine-UI/js/pages/registration-faq-view.js b/shine-UI/js/pages/registration-faq-view.js index 72845fbe..37800e42 100644 --- a/shine-UI/js/pages/registration-faq-view.js +++ b/shine-UI/js/pages/registration-faq-view.js @@ -86,6 +86,16 @@ export const REGISTRATION_FAQ_TOPICS = [ 'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.', ], }, + { + id: 'connect-device', + shortTitle: 'Подключить по коду', + title: 'Как подключить другое устройство?', + paragraphs: [ + 'На новом устройстве выберите вход через другое устройство и получите код подключения.', + 'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.', + 'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.', + ], + }, { id: 'hardware-device', shortTitle: 'ESP32', diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 1ae305d2..70c21511 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -86,7 +86,7 @@ export function render({ navigate }) { const deviceRow = createKeyInfo( deviceToggle, - 'Ключ device (всегда)', + 'Client key (всегда)', 'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.', ); diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 015ffec0..61c042c8 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -40,28 +40,28 @@ export function render({ navigate }) { const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = ` - - - + + - - - - + + `; card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); @@ -69,7 +69,10 @@ export function render({ navigate }) { card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view')); card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view')); - card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); + card.querySelector('#settings-language').addEventListener('click', () => { + sessionStorage.setItem('shine-language-return-page', 'settings-view'); + navigate('language-view'); + }); const signOutBtn = card.querySelector('#settings-signout'); signOutBtn.addEventListener('click', async () => { @@ -117,7 +120,7 @@ export function render({ navigate }) { const developerCard = document.createElement('div'); developerCard.className = 'card stack'; developerCard.innerHTML = ` - + `; developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); diff --git a/shine-UI/js/pages/start-view.js b/shine-UI/js/pages/start-view.js index d3ac224e..7b9c8df5 100644 --- a/shine-UI/js/pages/start-view.js +++ b/shine-UI/js/pages/start-view.js @@ -36,11 +36,14 @@ export function render({ navigate }) { registerButton.textContent = 'Зарегистрироваться'; registerButton.addEventListener('click', () => navigate('register-view')); - const guestViewButton = document.createElement('button'); - guestViewButton.className = 'shine-btn shine-btn--view'; - guestViewButton.type = 'button'; - guestViewButton.textContent = 'Только просмотр'; - guestViewButton.addEventListener('click', () => navigate('network-view')); + const languageButton = document.createElement('button'); + languageButton.className = 'shine-btn shine-btn--view'; + languageButton.type = 'button'; + languageButton.textContent = 'Язык / Language'; + languageButton.addEventListener('click', () => { + sessionStorage.setItem('shine-language-return-page', 'start-view'); + navigate('language-view'); + }); const settingsButton = document.createElement('button'); settingsButton.className = 'shine-btn shine-btn--settings'; @@ -48,7 +51,7 @@ export function render({ navigate }) { settingsButton.textContent = 'Настройки'; settingsButton.addEventListener('click', () => navigate('entry-settings-view')); - actions.append(loginButton, registerButton, guestViewButton, settingsButton); + actions.append(loginButton, registerButton, languageButton, settingsButton); screen.append(logoWrap, title, actions); return screen; } diff --git a/shine-UI/js/services/channels-ux.js b/shine-UI/js/services/channels-ux.js index a156440b..98f242b6 100644 --- a/shine-UI/js/services/channels-ux.js +++ b/shine-UI/js/services/channels-ux.js @@ -2,7 +2,7 @@ const rtf = (() => { try { - return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' }); + return new Intl.RelativeTimeFormat('ru', { numeric: 'always' }); } catch { return null; } @@ -33,41 +33,30 @@ export function formatRelativeTime(timestampMs) { if (!ts) return '—'; const now = Date.now(); + const dt = new Date(ts); + const nowDt = new Date(now); const diffSeconds = (ts - now) / 1000; - const ageSeconds = now >= ts ? (now - ts) / 1000 : 0; - const ageHours = ageSeconds / 3600; + const ageSeconds = Math.max(0, (now - ts) / 1000); + const ageDays = ageSeconds / 86400; - if (ageHours <= 10) { + if (ageDays < 7) { const [unit, value] = pickUnit(diffSeconds); if (rtf) return rtf.format(value, unit); - const absValue = Math.abs(value); const suffix = value <= 0 ? 'назад' : 'через'; - const labels = { - second: 'сек', - minute: 'мин', - hour: 'ч', - day: 'д', - month: 'мес', - year: 'г', - }; - return `${suffix} ${absValue} ${labels[unit] || ''}`.trim(); + const labels = { second: 'сек', minute: 'мин', hour: 'ч', day: 'д' }; + return `${absValue} ${labels[unit] || ''} ${suffix}`.trim(); } - try { - const dt = new Date(ts); - const nowDt = new Date(now); - const formatter = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - ...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}), - hour: '2-digit', - minute: '2-digit', - }); - return formatter.format(dt); - } catch { - return new Date(ts).toLocaleString(); + if (dt.getFullYear() === nowDt.getFullYear()) { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', + }).format(dt).replace(',', ','); } + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', month: '2-digit', year: 'numeric', + }).format(dt); } function ensureToastHost() { diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index e282d403..60c341bb 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -300,15 +300,15 @@ function createInitialState({ withStoredSession = true } = {}) { keyStorage: { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', - clientKey: 'Ключ device хранится в зашифрованном виде', + clientKey: 'Client key хранится в зашифрованном виде', saveRoot: true, saveBlockchain: true, - saveDevice: true, + saveClient: true, }, deviceConnect: { root: true, blockchain: true, - device: true, + client: true, }, authUi: { busy: false, @@ -848,6 +848,11 @@ export function checkServerAvailability(address) { return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable'; } +export function saveEntryLanguage(language) { + state.entrySettings.language = String(language || 'ru'); + persistEntrySettings(state.entrySettings); +} + export async function saveEntrySettings(nextSettings) { const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER); const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase() @@ -1013,7 +1018,7 @@ async function tryCloseCurrentSessionOnServer() { } } -export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false } = {}) { +export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) { if (closeServerSession) { await tryCloseCurrentSessionOnServer(); } @@ -1031,7 +1036,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes } catch { // ignore reconnect errors on sign out } - if (onSessionReset) { + if (notifySessionReset && onSessionReset) { onSessionReset(); } } diff --git a/shine-UI/styles/buttons-white.css b/shine-UI/styles/buttons-white.css index a7e0749c..5cfb0338 100644 --- a/shine-UI/styles/buttons-white.css +++ b/shine-UI/styles/buttons-white.css @@ -316,3 +316,62 @@ :root button.dm-message-action-btn--danger { color: #ffb7c5 !important; } + +/* 2026-08-28: исключения из borderless-reset для явных выборов и действий настроек. */ +:root body .language-choice-grid button.language-choice-option, +:root body .language-choice-grid button.language-choice-option:hover, +:root body .language-choice-grid button.language-choice-option:focus { + border: 1px solid rgba(210, 222, 241, 0.16) !important; + border-radius: 16px !important; + background: rgba(255, 255, 255, 0.035) !important; + box-shadow: none !important; + transform: none !important; +} + +:root body .language-choice-grid button.language-choice-option.is-selected, +:root body .language-choice-grid button.language-choice-option.is-selected:hover, +:root body .language-choice-grid button.language-choice-option.is-selected:focus { + border-color: rgba(92, 190, 255, 0.62) !important; + background: rgba(39, 141, 255, 0.12) !important; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important; +} + +:root body button.pairing-approve-btn, +:root body button.pairing-approve-btn:hover, +:root body button.pairing-approve-btn:focus { + background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important; + border: 1px solid rgba(126, 235, 170, .55) !important; + border-radius: 14px !important; + box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important; + transform: none !important; +} + +:root body button.pairing-reject-btn, +:root body button.pairing-reject-btn:hover, +:root body button.pairing-reject-btn:focus { + color: #fff1f3 !important; + background: rgba(134, 31, 49, .28) !important; + border: 1px solid rgba(255, 105, 128, .42) !important; + border-radius: 14px !important; + box-shadow: none !important; +} + +:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) { + border: 1px solid rgba(183, 203, 235, 0.28) !important; + border-radius: 14px !important; + background: + linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%), + rgba(8, 19, 42, .58) !important; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.10), + 0 5px 16px rgba(0,0,0,.18) !important; + padding-inline: 14px; + transform: none !important; +} + +:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover { + border-color: rgba(213, 225, 247, 0.42) !important; + background: + linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%), + rgba(10, 24, 52, .68) !important; +} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index cd052077..c276cc55 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -10008,3 +10008,309 @@ body.chat-topbar-overlay .composer-slot { @keyframes secret-generation-spin { to { transform: rotate(360deg); } } + +/* ===== 2026-08-28: единое dropdown-меню для чатов / каналов / профиля ===== */ +.shared-dropdown-menu { + position: fixed; + z-index: 12000; +} + +.shared-dropdown-menu__item.is-selected { + background: rgba(39, 141, 255, 0.14); +} + +.shared-dropdown-menu__icon { + width: 22px; + height: 22px; + min-width: 22px; + flex: 0 0 22px; + display: inline-grid; + place-items: center; +} + +.shared-dropdown-menu__icon > svg, +.shared-dropdown-menu__item > img { + width: 22px; + height: 22px; + min-width: 22px; + flex: 0 0 22px; + object-fit: contain; + color: #65b7ff; + filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28)); +} + +.profile-head-menu .shared-dropdown-menu__item > img { + width: 23px; + height: 23px; + min-width: 23px; + flex-basis: 23px; +} + +.dm-head-filter-title, +.channels-filter-title { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; +} + +.dm-head-filter-title::after, +.channels-filter-title::after { + content: '⌄'; + font-size: 13px; + line-height: 1; + opacity: 0.72; + transform: translateY(-1px); +} + +/* ===== Выбор языка со стартового экрана ===== */ +.language-screen { + width: min(100%, 430px); + margin-inline: auto; +} + +.language-choice-card { + padding: 16px; + background: rgba(38, 43, 52, 0.74); + border: 1px solid rgba(255, 255, 255, 0.10); +} + +.language-choice-hint { + margin: 0 0 4px; + text-align: center; +} + +.language-choice-grid { + display: grid; + gap: 10px; +} + +.language-choice-option { + min-height: 64px; + padding: 0 16px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; + border: 1px solid rgba(210, 222, 241, 0.16) !important; + border-radius: 16px; + background: rgba(255, 255, 255, 0.035) !important; + text-align: left; +} + +.language-choice-option.is-selected { + border-color: rgba(92, 190, 255, 0.62) !important; + background: rgba(39, 141, 255, 0.12) !important; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important; +} + +.language-choice-name { + font-size: 17px; + font-weight: 650; +} + +.language-choice-code { + color: rgba(255,255,255,.5); + font-size: 13px; + font-weight: 700; + letter-spacing: .08em; +} + +/* ===== Вход через другое устройство: заголовок внутри панели ===== */ +.auth-screen--other-device { + justify-content: flex-start; + padding-top: max(18px, env(safe-area-inset-top)); +} + +.auth-screen--other-device .login-panel { + margin-top: 0; +} + +.login-panel-inline-back { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + justify-content: flex-start !important; + gap: 10px; + padding: 0 2px; + font-size: 18px; + font-weight: 700; + text-align: left; +} + +.login-panel-inline-back > span:first-child { + font-size: 24px; + line-height: 1; +} + +/* ===== Pairing: простое Подключить / Отклонить + выбор доступных ключей ===== */ +:root button.pairing-approve-btn, +:root button.pairing-approve-btn:hover, +:root button.pairing-approve-btn:focus { + background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important; + border: 1px solid rgba(126, 235, 170, .55) !important; + border-radius: 14px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important; +} + +:root button.pairing-reject-btn, +:root button.pairing-reject-btn:hover, +:root button.pairing-reject-btn:focus { + color: #fff1f3 !important; + background: rgba(134, 31, 49, .28) !important; + border: 1px solid rgba(255, 105, 128, .42) !important; + border-radius: 14px; +} + +.pairing-request-actions > button { + flex: 1 1 140px; +} + +.pairing-transfer-dialog { + position: fixed; + inset: 0; + z-index: 13000; +} + +.pairing-transfer-dialog__backdrop { + position: absolute; + inset: 0; + background: rgba(3, 7, 15, .76); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.pairing-transfer-dialog__card { + position: absolute; + left: 50%; + top: 50%; + width: min(calc(100vw - 28px), 390px); + transform: translate(-50%, -50%); + padding: 16px; + gap: 14px; +} + +.pairing-transfer-key-list { + display: grid; + gap: 9px; +} + +.pairing-transfer-key { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 11px; + min-height: 58px; + padding: 9px 12px; + border: 1px solid rgba(255,255,255,.11); + border-radius: 14px; + background: rgba(255,255,255,.035); +} + +.pairing-transfer-key span { + display: grid; + gap: 2px; +} + +.pairing-transfer-key small { + color: rgba(255,255,255,.48); +} + +.pairing-transfer-key.is-required { + opacity: .82; +} + +.pairing-transfer-dialog__actions { + justify-content: flex-end; + flex-wrap: wrap; +} + +/* ===== Каналы: плотные широкие строки, badge сверху, время на линии preview ===== */ +.channels-screen--list .channels-list-content, +.channels-screen--list .channels-groups { + width: 100%; +} + +.channels-screen--list .channel-row { + position: relative; + width: calc(100% - 8px); + grid-template-columns: 58px minmax(0, 1fr); + gap: 10px; + min-height: 64px; + margin: 2px 4px; + padding: 5px 8px 5px 5px; + border-radius: 14px; +} + +.channels-screen--list .channel-row .avatar { + width: 52px; + height: 52px; + min-width: 52px; + min-height: 52px; +} + +.channels-screen--list .channel-row-main { + min-width: 0; + padding-right: 6px; + gap: 1px; +} + +.channels-screen--list .channel-row-preview-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.channels-screen--list .channel-row-preview-line .channel-row-message { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channels-screen--list .channel-row-preview-line .channel-row-time { + align-self: baseline; + margin: 0; + white-space: nowrap; + font-size: 11px; +} + +.channels-screen--list .channel-row-controls { + position: absolute; + top: 5px; + right: 7px; + width: auto; + min-width: 0; + height: auto; +} + +.channels-screen--list .channel-row-count, +.channels-screen--list .unread.channel-row-count { + position: static !important; + margin: 0 !important; +} + +/* ===== Вложенные настройки: тот же рамочный язык действий ===== */ +:root .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) { + border: 1px solid rgba(183, 203, 235, 0.28) !important; + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%), + rgba(8, 19, 42, .58) !important; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.10), + 0 5px 16px rgba(0,0,0,.18) !important; + padding-inline: 14px; +} + +:root .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover { + border-color: rgba(213, 225, 247, 0.42) !important; + background: + linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%), + rgba(10, 24, 52, .68) !important; +}