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) {
- Подключить и передать ключи
+
+