From 56a69ab683b1c690345ea7b69926476e64458ed4349f6b74481dcc474dbe9f92 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 14 May 2026 14:16:03 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20UI-=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA,=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=87=D0=B0=D1=82,=20=D1=80=D1=83=D1=81=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B5=20pending-=D1=84=D0=B0=D0=B9=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + ...голосовые-инструменты-openai-tts-и-stt.md} | 0 ...5-13_0222_argon2id-логин-и-регистрация.md} | 0 ...ки-каналов-и-legacy-fallback-createchannel.md} | 0 ..._0258_исправление-версии-createchannel.md} | 0 ...-05-14_1236_thread-действия-и-счетчики.md} | 0 ..._1243_каналы-поиск-просмотр-и-подписка.md} | 0 ..._уведомления-заглушки-и-правило-intake.md} | 0 ...бки-в-сервер-и-персональный-публичный-чат.md | 31 +++ Dev_Docs/Pending_Features/README.md | 1 + VERSION.properties | 4 +- shine-UI/js/app.js | 12 +- .../js/pages/add-personal-public-chat-view.js | 223 ++++++++++++++++++ shine-UI/js/pages/channel-thread-view.js | 57 +++-- shine-UI/js/pages/channel-view.js | 157 +++++------- shine-UI/js/pages/channels-list.js | 41 ++-- shine-UI/js/pages/developer-settings-view.js | 38 +++ shine-UI/js/router.js | 21 +- shine-UI/js/services/auth-service.js | 17 ++ shine-UI/js/services/client-error-reporter.js | 31 ++- shine-UI/styles/components.css | 7 + 21 files changed, 488 insertions(+), 153 deletions(-) rename Dev_Docs/Pending_Features/{2026-05-13_0201_voice-tools-openai-tts-and-stt.md => 2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-13_0222_argon2id-login-register.md => 2026-05-13_0222_argon2id-логин-и-регистрация.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-13_0248_channels-tabs-and-legacy-createchannel-fallback.md => 2026-05-13_0248_вкладки-каналов-и-legacy-fallback-createchannel.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-13_0258_createchannel-version-fix.md => 2026-05-13_0258_исправление-версии-createchannel.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-14_1236_thread-actions-counters-layout.md => 2026-05-14_1236_thread-действия-и-счетчики.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md => 2026-05-14_1243_каналы-поиск-просмотр-и-подписка.md} (100%) rename Dev_Docs/Pending_Features/{2026-05-14_1327_notifications-placeholders-and-agents-intake-flow.md => 2026-05-14_1327_уведомления-заглушки-и-правило-intake.md} (100%) create mode 100644 Dev_Docs/Pending_Features/2026-05-14_1414_ui-ошибки-в-сервер-и-персональный-публичный-чат.md create mode 100644 shine-UI/js/pages/add-personal-public-chat-view.js diff --git a/AGENTS.md b/AGENTS.md index 2d9385c..7a11373 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ - Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`. - По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`. - Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_.md`. +- Имена новых файлов и краткие описания фич по возможности писать на русском языке. - Внутри файла обязательно указывать: - краткое описание фичи; - что именно проверять; diff --git a/Dev_Docs/Pending_Features/2026-05-13_0201_voice-tools-openai-tts-and-stt.md b/Dev_Docs/Pending_Features/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0201_voice-tools-openai-tts-and-stt.md rename to Dev_Docs/Pending_Features/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md diff --git a/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-login-register.md b/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-логин-и-регистрация.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-login-register.md rename to Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-логин-и-регистрация.md diff --git a/Dev_Docs/Pending_Features/2026-05-13_0248_channels-tabs-and-legacy-createchannel-fallback.md b/Dev_Docs/Pending_Features/2026-05-13_0248_вкладки-каналов-и-legacy-fallback-createchannel.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0248_channels-tabs-and-legacy-createchannel-fallback.md rename to Dev_Docs/Pending_Features/2026-05-13_0248_вкладки-каналов-и-legacy-fallback-createchannel.md diff --git a/Dev_Docs/Pending_Features/2026-05-13_0258_createchannel-version-fix.md b/Dev_Docs/Pending_Features/2026-05-13_0258_исправление-версии-createchannel.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0258_createchannel-version-fix.md rename to Dev_Docs/Pending_Features/2026-05-13_0258_исправление-версии-createchannel.md diff --git a/Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md b/Dev_Docs/Pending_Features/2026-05-14_1236_thread-действия-и-счетчики.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md rename to Dev_Docs/Pending_Features/2026-05-14_1236_thread-действия-и-счетчики.md diff --git a/Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md b/Dev_Docs/Pending_Features/2026-05-14_1243_каналы-поиск-просмотр-и-подписка.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md rename to Dev_Docs/Pending_Features/2026-05-14_1243_каналы-поиск-просмотр-и-подписка.md diff --git a/Dev_Docs/Pending_Features/2026-05-14_1327_notifications-placeholders-and-agents-intake-flow.md b/Dev_Docs/Pending_Features/2026-05-14_1327_уведомления-заглушки-и-правило-intake.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-14_1327_notifications-placeholders-and-agents-intake-flow.md rename to Dev_Docs/Pending_Features/2026-05-14_1327_уведомления-заглушки-и-правило-intake.md diff --git a/Dev_Docs/Pending_Features/2026-05-14_1414_ui-ошибки-в-сервер-и-персональный-публичный-чат.md b/Dev_Docs/Pending_Features/2026-05-14_1414_ui-ошибки-в-сервер-и-персональный-публичный-чат.md new file mode 100644 index 0000000..5b4452b --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-14_1414_ui-ошибки-в-сервер-и-персональный-публичный-чат.md @@ -0,0 +1,31 @@ +# UI-ошибки в сервер + новый сценарий персонального публичного чата + +- краткое описание фичи: + - Добавлена настройка разработчика «Отправлять ошибки на сервер» (по умолчанию выключена), с локальным сохранением. + - При включенной настройке UI-ошибки отправляются в `CallDeliveryReport` с `type=ui_error` и отдельным кодом `UI_RUNTIME_ERROR`. + - После успешной отправки показывается toast: «Ошибка отправлена на сервер · · <время>». + - Для вкладки `Чаты` кнопка переименована в «Новый персональный публичный чат». + - Добавлен отдельный экран создания персонального публичного чата: + - фиксированный `channelType=100`; + - ввод логина второго пользователя; + - поиск/подсказки пользователей; + - создание канала с каноническим логином из сервера; + - опциональное описание; + - предупреждение про публичность и хранение в блокчейне. + - Обновлены правила документации: имена pending-файлов и описания новых фич рекомендованы на русском. + +- что именно проверять: + - В `Настройки разработчика` открыть «Отправлять ошибки на сервер», включить и сохранить. + - Сгенерировать UI-ошибку и проверить: + - появляется toast об отправке; + - запись появляется в `logs/call-delivery-events.log` с `type=ui_error`. + - На вкладке `Каналы -> Чаты` проверить новую кнопку «Новый персональный публичный чат». + - Проверить форму создания: подсказки логинов, создание с правильным регистром логина, описание и инфоблок. + +- ожидаемый результат: + - UI-ошибки начинают отправляться только при включенной настройке. + - В логах сервера UI-ошибки отделяются по типу `ui_error`. + - Персональный публичный чат создается через отдельный, более понятный пользовательский сценарий. + +- статус: + - pending diff --git a/Dev_Docs/Pending_Features/README.md b/Dev_Docs/Pending_Features/README.md index bd4722f..071183a 100644 --- a/Dev_Docs/Pending_Features/README.md +++ b/Dev_Docs/Pending_Features/README.md @@ -6,6 +6,7 @@ 1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл: - формат: `YYYY-MM-DD_HHMM_.md` + - название `` и текст файла по возможности писать на русском языке 2. В файле указать: - что сделано; - как проверять; diff --git a/VERSION.properties b/VERSION.properties index 5e8e3c5..dcd79e5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.52 -server.version=1.2.46 +client.version=1.2.53 +server.version=1.2.47 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index d87ea22..225b149 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1,9 +1,10 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; import { renderToolbar } from './components/toolbar.js'; -import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js'; +import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'; import { initPwaPush } from './services/pwa-push-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js'; +import { showToast } from './services/channels-ux.js'; import { handleCallPushAction, handleIncomingCallInvite, @@ -66,6 +67,7 @@ import * as channelsList from './pages/channels-list.js'; import * as channelView from './pages/channel-view.js'; import * as channelThreadView from './pages/channel-thread-view.js'; import * as addChannelView from './pages/add-channel-view.js'; +import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as networkView from './pages/network-view.js'; import * as notificationsView from './pages/notifications-view.js'; @@ -104,6 +106,7 @@ const routes = { 'channel-view': channelView, 'channel-thread-view': channelThreadView, 'add-channel-view': addChannelView, + 'add-personal-public-chat-view': addPersonalPublicChatView, 'network-view': networkView, 'notifications-view': notificationsView, }; @@ -134,7 +137,12 @@ let uiVersionCheckInFlight = false; let uiVersionPeriodicIntervalId = null; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; -setClientErrorTransport((payload) => authService.reportClientError(payload)); +setClientErrorTransport((payload) => authService.reportClientUiError(payload)); +setClientErrorSentNotifier((payload) => { + const login = String(state.session.login || 'guest').trim(); + const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString(); + showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`); +}); initPwaInstallPromptHandling(); initCallUiOverlay(); setCallDebugReporter((payload) => authService.reportClientDebug(payload)); diff --git a/shine-UI/js/pages/add-personal-public-chat-view.js b/shine-UI/js/pages/add-personal-public-chat-view.js new file mode 100644 index 0000000..650b376 --- /dev/null +++ b/shine-UI/js/pages/add-personal-public-chat-view.js @@ -0,0 +1,223 @@ +import { renderHeader } from '../components/header.js'; +import { authService, state } from '../state.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; +import { normalizeChannelDescription } from '../services/channel-name-rules.js'; + +export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' }; + +const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; +const CHANNEL_TYPE_PERSONAL = 100; + +function persistCreateSuccessFlash(message) { + try { + sessionStorage.setItem(CREATE_CHANNEL_FLASH_KEY, String(message || '').trim()); + } catch { + // ignore storage errors + } +} + +function normalizeLoginInput(value) { + return String(value || '').trim().replace(/^@+/, ''); +} + +function isValidLogin(value) { + const clean = normalizeLoginInput(value); + if (!clean) return false; + if (clean.length < 1 || clean.length > 20) return false; + return /^[A-Za-z0-9_]+$/.test(clean); +} + +function validateDescription(value) { + const normalized = normalizeChannelDescription(value); + const bytes = new TextEncoder().encode(normalized).length; + if (bytes > 200) { + return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' }; + } + return { ok: true, normalized, bytes, error: '' }; +} + +function createDebounced(fn, delayMs = 240) { + let timer = null; + return (...args) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn(...args); + }, delayMs); + }; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack channels-screen channels-screen--add'; + + screen.append( + renderHeader({ + title: 'Новый персональный публичный чат', + leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') }, + }), + ); + + const form = document.createElement('form'); + form.className = 'card stack'; + form.innerHTML = ` + Создание персонального публичного чата +

Тип канала фиксирован: персональный (100).

+ + + + +
+ + + +
0 / 200 байт
+
+ +
+ Публичные чаты могут просматривать любые пользователи, и сообщения сохраняются в блокчейне. + Для личной приватной переписки используйте вкладку «Личные сообщения». +
+ +
+
+ + +
+ `; + + const loginEl = form.querySelector('#chat-login'); + const suggestEl = form.querySelector('#chat-login-suggest'); + const loginErrorEl = form.querySelector('#chat-login-error'); + const descriptionEl = form.querySelector('#chat-description'); + const descriptionErrorEl = form.querySelector('#chat-description-error'); + const descriptionCounterEl = form.querySelector('#chat-description-counter'); + const errorEl = form.querySelector('#chat-create-error'); + const submitEl = form.querySelector('#submit-create-chat'); + const cancelEl = form.querySelector('#cancel-create-chat'); + + let submitInFlight = false; + let selectedCanonicalLogin = ''; + + const setBusy = (busy) => { + submitInFlight = !!busy; + submitEl.disabled = submitInFlight; + cancelEl.disabled = submitInFlight; + loginEl.disabled = submitInFlight; + descriptionEl.disabled = submitInFlight; + submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать чат'; + }; + + const renderLoginSuggestions = (logins) => { + suggestEl.innerHTML = ''; + const rows = Array.isArray(logins) ? logins.filter(Boolean) : []; + if (!rows.length) { + suggestEl.style.display = 'none'; + return; + } + suggestEl.style.display = ''; + rows.slice(0, 8).forEach((login) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'channel-search-item'; + btn.textContent = String(login); + btn.addEventListener('click', () => { + selectedCanonicalLogin = String(login); + loginEl.value = selectedCanonicalLogin; + suggestEl.style.display = 'none'; + }); + suggestEl.append(btn); + }); + }; + + const updateValidation = () => { + const loginRaw = String(loginEl.value || '').trim(); + const loginOk = isValidLogin(loginRaw); + const descriptionCheck = validateDescription(descriptionEl.value); + + loginErrorEl.textContent = loginOk ? '' : 'Логин: 1-20 символов, латиница/цифры/_.'; + descriptionErrorEl.textContent = descriptionCheck.error; + descriptionCounterEl.textContent = `${Number(descriptionCheck.bytes || 0)} / 200 байт`; + + const ok = loginOk && descriptionCheck.ok; + submitEl.disabled = submitInFlight || !ok; + return { ok, description: descriptionCheck.normalized }; + }; + + const refreshSuggestions = createDebounced(async () => { + if (submitInFlight) return; + const loginRaw = normalizeLoginInput(loginEl.value); + if (loginRaw.length < 1) { + suggestEl.style.display = 'none'; + suggestEl.innerHTML = ''; + return; + } + try { + const logins = await authService.searchUsers(loginRaw); + renderLoginSuggestions(logins); + } catch { + suggestEl.style.display = 'none'; + suggestEl.innerHTML = ''; + } + }, 220); + + loginEl.addEventListener('input', () => { + selectedCanonicalLogin = ''; + errorEl.textContent = ''; + updateValidation(); + refreshSuggestions(); + }); + descriptionEl.addEventListener('input', updateValidation); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + if (submitInFlight) return; + + const login = state.session.login; + const storagePwd = state.session.storagePwdInMemory; + if (!login || !storagePwd) { + errorEl.textContent = 'Сессия недействительна. Выполните вход заново.'; + return; + } + + const validation = updateValidation(); + if (!validation.ok) return; + + setBusy(true); + errorEl.textContent = ''; + loginErrorEl.textContent = ''; + + try { + const inputLogin = normalizeLoginInput(loginEl.value); + const foundUser = await authService.getUser(inputLogin); + if (!foundUser?.exists) { + throw new Error('Пользователь с таким логином не найден.'); + } + const canonicalLogin = String(foundUser?.login || inputLogin).trim(); + if (!canonicalLogin) throw new Error('Не удалось определить логин пользователя.'); + + await authService.addBlockCreateChannel({ + login, + storagePwd, + channelName: canonicalLogin, + channelDescription: validation.description, + channelType: CHANNEL_TYPE_PERSONAL, + channelTypeVersion: 1, + }); + + persistCreateSuccessFlash(`Публичный чат с "${canonicalLogin}" создан.`); + navigate('channels-list/dialogs'); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось создать персональный публичный чат.'); + setBusy(false); + updateValidation(); + } + }); + + cancelEl.addEventListener('click', () => navigate('channels-list/dialogs')); + + screen.append(form); + loginEl.focus(); + updateValidation(); + return screen; +} diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 0b49194..7553da3 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -75,10 +75,10 @@ function buildAbsoluteRouteUrl(routePath = '') { function parseThreadSelector(route) { const params = route?.params || {}; - if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) { + if (params.ownerBlockchainName && params.channelName && params.messageBlockNumber) { return { short: { - ownerLogin: String(params.ownerLogin || '').trim(), + ownerBlockchainName: String(params.ownerBlockchainName || '').trim(), channelName: String(params.channelName || '').trim(), }, message: { @@ -135,13 +135,11 @@ function resolveChannelDisplayName(channelSelector) { } function buildBackRoute(selector) { - const channel = selector?.channel; - if (channel?.ownerBlockchainName && channel.rootBlockNumber != null) { + if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { return [ - 'channel-view', - encodeRoutePart(channel.ownerBlockchainName), - channel.rootBlockNumber, - channel.rootBlockHash, + 'channel', + encodeRoutePart(selector.short.ownerBlockchainName), + encodeRoutePart(selector.short.channelName), ].join('/'); } return 'channels-list'; @@ -149,13 +147,12 @@ function buildBackRoute(selector) { function buildThreadRouteFromTarget(target, selector) { if (!target) return ''; - if (selector?.short?.ownerLogin && selector?.short?.channelName) { + if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { return [ 'channel', - encodeRoutePart(selector.short.ownerLogin), + encodeRoutePart(selector.short.ownerBlockchainName), encodeRoutePart(selector.short.channelName), target.blockNumber, - normalizeRouteHash(target.blockHash), ].join('/'); } if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return ''; @@ -577,20 +574,44 @@ export function render({ navigate, route }) { (async () => { try { let resolvedMessage = selector.message; - if (selector.short?.ownerLogin && selector.short?.channelName) { - const ownerFeed = await authService.listSubscriptionsFeed(selector.short.ownerLogin, 1000); - const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; - const channel = ownChannels.find((item) => ( - String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase() + if (selector.short?.ownerBlockchainName && selector.short?.channelName) { + const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); + const allRows = [ + ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), + ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), + ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), + ]; + const channel = allRows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '').trim() === selector.short.ownerBlockchainName + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase() )); const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim(); - if (!ownerBch || !Number.isFinite(resolvedMessage?.blockNumber)) { + const rootNo = Number(channel?.channel?.channelRoot?.blockNumber); + const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash); + if (!ownerBch || !Number.isFinite(rootNo) || !Number.isFinite(resolvedMessage?.blockNumber)) { throw new Error('Канал или сообщение не найдено.'); } + selector.channel = { + ownerBlockchainName: ownerBch, + rootBlockNumber: rootNo, + rootBlockHash: rootHash, + }; + + let resolvedHash = normalizeMessageHash(resolvedMessage?.blockHash); + if (!resolvedHash) { + const channelPayload = await authService.getChannelMessages(selector.channel, 400, 'asc', state.session.login); + const messages = Array.isArray(channelPayload?.messages) ? channelPayload.messages : []; + const foundMessage = messages.find((item) => Number(item?.messageRef?.blockNumber) === Number(resolvedMessage.blockNumber)); + const foundHash = normalizeMessageHash(foundMessage?.messageRef?.blockHash); + if (!foundHash) { + throw new Error('Не удалось определить hash сообщения для открытия треда.'); + } + resolvedHash = foundHash; + } resolvedMessage = { blockchainName: ownerBch, blockNumber: resolvedMessage.blockNumber, - blockHash: normalizeRouteHash(resolvedMessage.blockHash), + blockHash: resolvedHash, }; } diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 0d8cbb3..3259a54 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -22,7 +22,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' }; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); -const revealedCountersByRoute = new Map(); function isChannelsDemoMode() { try { @@ -95,29 +94,6 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') { return `${blockchainName}:${blockNumber}:${blockHash}`; } -function getRevealedCounterSet(routeKey) { - const key = String(routeKey || '').trim(); - if (!key) return new Set(); - let bucket = revealedCountersByRoute.get(key); - if (!bucket) { - bucket = new Set(); - revealedCountersByRoute.set(key, bucket); - } - return bucket; -} - -function isCounterVisible(routeKey, counterKey) { - const key = String(counterKey || '').trim(); - if (!key) return false; - return getRevealedCounterSet(routeKey).has(key); -} - -function revealCounter(routeKey, counterKey) { - const key = String(counterKey || '').trim(); - if (!key) return; - getRevealedCounterSet(routeKey).add(key); -} - function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); @@ -128,9 +104,9 @@ function buildAbsoluteRouteUrl(routePath = '') { function buildSelectorFromRoute(route, channelId) { const params = route?.params || {}; - if (params.ownerLogin && params.channelName) { + if (params.ownerBlockchainName && params.channelName) { return { - ownerLogin: String(params.ownerLogin || '').trim(), + ownerBlockchainName: String(params.ownerBlockchainName || '').trim(), channelName: String(params.channelName || '').trim(), }; } @@ -157,15 +133,14 @@ function buildSelectorFromRoute(route, channelId) { function buildThreadRoute(messageRef, selector) { if (!messageRef || !selector) return ''; - const ownerLogin = String(selector.ownerLogin || '').trim(); + const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim(); const channelName = String(selector.channelName || '').trim(); - if (ownerLogin && channelName) { + if (ownerBlockchainName && channelName) { return [ 'channel', - encodeRoutePart(ownerLogin), + encodeRoutePart(ownerBlockchainName), encodeRoutePart(channelName), messageRef.blockNumber, - normalizeRouteHash(messageRef.blockHash), ].join('/'); } return [ @@ -434,11 +409,16 @@ function mapApiMessageToPost(message, selector, localNumber) { async function loadFromApi(route, channelId) { let selector = buildSelectorFromRoute(route, channelId); - if (selector?.ownerLogin && selector?.channelName) { - const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000); - const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; - const channel = ownChannels.find((item) => ( - String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + if (selector?.ownerBlockchainName && selector?.channelName) { + const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); + const allRows = [ + ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), + ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), + ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), + ]; + const channel = allRows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '').trim() === selector.ownerBlockchainName + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { throw new Error('Канал не найден.'); @@ -447,7 +427,6 @@ async function loadFromApi(route, channelId) { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash), - ownerLogin: selector.ownerLogin, channelName: selector.channelName, }; } @@ -550,9 +529,7 @@ function applyPendingScroll(screen, routeKey) { function renderPostCard(post, { navigate, - routeKey, selector, - canWrite, onToggleLike, onReply, onShare, @@ -598,72 +575,57 @@ function renderPostCard(post, { if (refKey) { card.dataset.messageKey = refKey; } - const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true; - if (!countersVisible) { - card.classList.remove('is-counters-visible'); - } else { - card.classList.add('is-counters-visible'); - } - - const revealCounters = () => { - if (!refKey) return; - revealCounter(routeKey, refKey); - card.classList.add('is-counters-visible'); - }; - card.addEventListener('click', revealCounters); + card.classList.add('is-counters-visible'); if (!post.messageRef || !selector) return card; const actions = document.createElement('div'); actions.className = 'channel-message-actions'; - if (canWrite) { - const actionKey = makeReactionActionKey(post.messageRef); - const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; + const actionKey = makeReactionActionKey(post.messageRef); + const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; - const likeButton = document.createElement('button'); - likeButton.type = 'button'; - likeButton.className = 'channel-action-item channel-action-like'; - const isLiked = post.reactionState === 'liked'; - if (isLiked) likeButton.classList.add('is-liked'); - likeButton.innerHTML = ` - - ${isPending ? 'Лайк...' : 'Лайк'} - ${post.likesCount || 0} - `; - likeButton.disabled = isPending; - likeButton.addEventListener('click', async (event) => { - animatePress(event.currentTarget); - if (isPending) return; - if (!isLiked) { - const ok = window.confirm('Поставить лайк?'); - if (!ok) return; - } - revealCounters(); - await longPressFeel(event.currentTarget, 130); - likeButton.disabled = true; - const labelEl = likeButton.querySelector('.channel-action-label'); - if (labelEl) labelEl.textContent = 'Лайк...'; - await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); - }); + const likeButton = document.createElement('button'); + likeButton.type = 'button'; + likeButton.className = 'channel-action-item channel-action-like'; + const isLiked = post.reactionState === 'liked'; + if (isLiked) likeButton.classList.add('is-liked'); + likeButton.innerHTML = ` + + ${isPending ? 'Лайк...' : 'Лайк'} + ${post.likesCount || 0} + `; + likeButton.disabled = isPending; + likeButton.addEventListener('click', async (event) => { + animatePress(event.currentTarget); + if (isPending) return; + if (!isLiked) { + const ok = window.confirm('Поставить лайк?'); + if (!ok) return; + } + await longPressFeel(event.currentTarget, 130); + likeButton.disabled = true; + const labelEl = likeButton.querySelector('.channel-action-label'); + if (labelEl) labelEl.textContent = 'Лайк...'; + await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); + }); - const replyButton = document.createElement('button'); - replyButton.type = 'button'; - replyButton.className = 'channel-action-item channel-action-reply'; - replyButton.innerHTML = ` - - Ответить - `; - replyButton.addEventListener('click', (event) => { - animatePress(event.currentTarget); - revealCounters(); - openReplyModal({ - navigate, - onSubmit: async (text) => onReply(post.messageRef, text), - }); + const replyButton = document.createElement('button'); + replyButton.type = 'button'; + replyButton.className = 'channel-action-item channel-action-reply'; + replyButton.innerHTML = ` + + Ответить + ${post.repliesCount || 0} + `; + replyButton.addEventListener('click', (event) => { + animatePress(event.currentTarget); + openReplyModal({ + navigate, + onSubmit: async (text) => onReply(post.messageRef, text), }); - actions.append(likeButton, replyButton); - } + }); + actions.append(likeButton, replyButton); const openThreadButton = document.createElement('button'); openThreadButton.type = 'button'; @@ -671,11 +633,9 @@ function renderPostCard(post, { openThreadButton.innerHTML = ` Тред - ${post.repliesCount || 0} `; openThreadButton.addEventListener('click', (event) => { animatePress(event.currentTarget); - revealCounters(); const route = buildThreadRoute(post.messageRef, selector); if (route) navigate(route); }); @@ -690,7 +650,6 @@ function renderPostCard(post, { shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); - revealCounters(); const route = buildThreadRoute(post.messageRef, selector); await onShare(route); }); @@ -741,9 +700,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { channelData.posts.forEach((post) => { const row = renderPostCard(post, { navigate, - routeKey, selector: channelData.selector, - canWrite: channelData.isOwnChannel, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onShare: handlers.onShare, diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 198acc5..3df2990 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -44,17 +44,11 @@ function normalizeLoginInput(value) { function buildChannelRouteFromSummary(summary, fallbackId) { const ownerBch = summary?.channel?.ownerBlockchainName; - const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber; - const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash); - if (ownerBch && rootBlockNumber != null) { - return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`; - } - const ownerLogin = String(summary?.channel?.ownerLogin || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim(); - if (ownerLogin && channelName) { - return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`; + if (ownerBch && channelName) { + return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`; } - return `channel-view/${fallbackId}`; + return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`; } function avatarLetterFromName(name = '') { @@ -468,7 +462,8 @@ function openChannelFinderModal({ navigate }) { openBtn.textContent = 'Просмотреть'; openBtn.addEventListener('click', () => { close(); - navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`); + const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim(); + navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`); }); row.style.display = 'flex'; @@ -487,11 +482,19 @@ function openChannelFinderModal({ navigate }) { const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const needle = String(filterChannel || '').trim().toLowerCase(); const channels = rows - .map((item) => String(item?.channel?.channelName || '').trim()) - .filter(Boolean) - .filter((name) => !needle || name.toLowerCase().includes(needle)) + .map((item) => ({ + ownerBlockchainName: String(item?.channel?.ownerBlockchainName || '').trim(), + channelName: String(item?.channel?.channelName || '').trim(), + })) + .filter((item) => !!item.channelName) + .filter((item) => !needle || item.channelName.toLowerCase().includes(needle)) .slice(0, 200) - .map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name })); + .map((item) => ({ + label: `${ownerLogin}/${item.channelName}`, + ownerLogin, + ownerBlockchainName: item.ownerBlockchainName, + channelName: item.channelName, + })); renderChannelRows(channels); }; @@ -550,7 +553,7 @@ function openChannelFinderModal({ navigate }) { function mapMockGroups() { const mapRow = (channel) => ({ ...channel, - route: `channel-view/${channel.id}`, + route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`, tabCategory: channel.kind === 'own' ? 'my' : channel.kind === 'own-personal' @@ -737,7 +740,7 @@ function renderDemoFallback(container, navigate, error, onRetry) { `; - row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`)); + row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); list.append(row); }); @@ -1012,7 +1015,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed controls.append(menuButton, time, count); row.append(avatar, main, controls); - row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`)); + row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); list.append(row); }); @@ -1031,9 +1034,9 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) { } if (tab === 'dialogs') { - button.textContent = 'Новый персональный канал'; + button.textContent = 'Новый персональный публичный чат'; button.className = baseClass; - button.onclick = () => navigate('add-channel-view'); + button.onclick = () => navigate('add-personal-public-chat-view'); return; } diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js index e39911c..1a7c8c2 100644 --- a/shine-UI/js/pages/developer-settings-view.js +++ b/shine-UI/js/pages/developer-settings-view.js @@ -1,5 +1,9 @@ import { renderHeader } from '../components/header.js'; import { addAppLogEntry, authService, state } from '../state.js'; +import { + isClientErrorReportingEnabled, + setClientErrorReportingEnabled, +} from '../services/client-error-reporter.js'; import { canInstallPwa, isStandalonePwaMode, @@ -209,6 +213,37 @@ function showClientUpdateHelp() { ); } +function openUiErrorReportingModal() { + const root = document.getElementById('modal-root'); + if (!root) return; + + const enabled = isClientErrorReportingEnabled(); + root.innerHTML = ` + + `; + + const close = () => { root.innerHTML = ''; }; + root.querySelector('#ui-error-reporting-cancel')?.addEventListener('click', close); + root.querySelector('#ui-error-reporting-save')?.addEventListener('click', () => { + const checked = root.querySelector('#ui-error-reporting-toggle')?.checked === true; + setClientErrorReportingEnabled(checked); + close(); + }); +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -225,6 +260,7 @@ export function render({ navigate }) { card.innerHTML = ` + @@ -237,6 +273,7 @@ export function render({ navigate }) { const uploadAvatarBtn = card.querySelector('#settings-upload-avatar'); const forceUpdateBtn = card.querySelector('#settings-force-ui-update'); const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help'); + const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting'); appLogBtn?.addEventListener('click', () => navigate('app-log-view')); diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view')); @@ -248,6 +285,7 @@ export function render({ navigate }) { }); }); forceUpdateHelpBtn?.addEventListener('click', showClientUpdateHelp); + uiErrorReportingBtn?.addEventListener('click', openUiErrorReportingModal); forceUpdateBtn?.addEventListener('click', async () => { forceUpdateBtn.disabled = true; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index f9e820e..9378a0b 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -51,25 +51,24 @@ export function getRoute() { } if (pageId === 'channel') { - // Новый короткий формат: - // #/channel/{login}/{channelName} - // #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash} - const ownerLogin = decodePart(segments[1] || ''); + // Короткий формат: + // #/channel/{ownerBlockchainName}/{channelName} + // #/channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber} + const ownerBlockchainName = decodePart(segments[1] || ''); const channelName = decodePart(segments[2] || ''); const messageBlockNumber = segments[3] || ''; - const messageBlockHash = segments[4] || ''; - if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) { + if (ownerBlockchainName && channelName && messageBlockNumber) { return { pageId: 'channel-thread-view', params: { - ownerLogin, + ownerBlockchainName, channelName, messageBlockNumber, - messageBlockHash, + messageBlockHash: '', // поддержка старого контракта страницы треда messageBlockchainName: '', - channelOwnerBlockchainName: '', + channelOwnerBlockchainName: ownerBlockchainName, channelRootBlockNumber: '', channelRootBlockHash: '', }, @@ -79,7 +78,7 @@ export function getRoute() { return { pageId: 'channel-view', params: { - ownerLogin, + ownerBlockchainName, channelName, channelId: '', }, @@ -161,7 +160,7 @@ export function resolveToolbarActive(pageId) { return 'profile-view'; } if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; - if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view') return 'channels-list'; + if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'user-profile-view') return 'messages-list'; return 'profile-view'; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 88af26f..ab92cbe 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1682,6 +1682,23 @@ export class AuthService { } } + async reportClientUiError(details = {}) { + try { + const payload = { + source: 'ui_error', + code: 'UI_RUNTIME_ERROR', + ...details, + }; + const response = await this.sendCallDeliveryReport({ + type: 'ui_error', + value: JSON.stringify(payload), + }); + return !!response; + } catch { + return false; + } + } + close() { this.ws.close(); } diff --git a/shine-UI/js/services/client-error-reporter.js b/shine-UI/js/services/client-error-reporter.js index ce75528..4bcbca7 100644 --- a/shine-UI/js/services/client-error-reporter.js +++ b/shine-UI/js/services/client-error-reporter.js @@ -1,9 +1,11 @@ const MAX_CONTEXT_LEN = 2000; const RECENT_WINDOW_MS = 5000; +const UI_ERROR_REPORTING_KEY = 'shine-ui-send-errors-to-server-v1'; let transport = null; let transportDepth = 0; const recentFingerprints = new Map(); +let notifySent = null; function nowTs() { return Date.now(); @@ -79,6 +81,26 @@ export function setClientErrorTransport(fn) { transport = typeof fn === 'function' ? fn : null; } +export function setClientErrorSentNotifier(fn) { + notifySent = typeof fn === 'function' ? fn : null; +} + +export function isClientErrorReportingEnabled() { + try { + return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1'; + } catch { + return false; + } +} + +export function setClientErrorReportingEnabled(enabled) { + try { + localStorage.setItem(UI_ERROR_REPORTING_KEY, enabled ? '1' : '0'); + } catch { + // ignore storage errors + } +} + export async function captureClientError(details = {}) { const payload = buildPayload(details); if (!payload.message) return false; @@ -88,13 +110,20 @@ export async function captureClientError(details = {}) { console.error('[client-error]', payload.kind, payload.message, details.error || ''); - if (!transport || details.skipTransport === true || transportDepth > 0) { + if (!transport || details.skipTransport === true || transportDepth > 0 || !isClientErrorReportingEnabled()) { return false; } try { transportDepth += 1; await transport(payload); + if (notifySent) { + try { + notifySent(payload); + } catch { + // ignore notifier errors + } + } return true; } catch (error) { console.warn('client error transport failed', error); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 69c178a..65acf78 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -3714,6 +3714,13 @@ textarea.input { transform: none; } +/* Thread cards should stay fixed without breathing motion */ +.channels-screen--thread .thread-node-card, +.channels-screen--thread .thread-block, +.channels-screen--thread .thread-summary { + animation: none !important; +} + /* 2) Static controls with energy + glass glare (no levitation) */ .channels-screen--list .channels-tab-btn, .channels-screen--list .channels-bottom-action,