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) { -