UI: отправка UI-ошибок, персональный публичный чат, русские pending-файлы

This commit is contained in:
AidarKC 2026-05-14 14:16:03 +03:00
parent e73e103ac4
commit 56a69ab683
21 changed files with 488 additions and 153 deletions

View File

@ -54,6 +54,7 @@
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`. - Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`. - По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`. - Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
- Имена новых файлов и краткие описания фич по возможности писать на русском языке.
- Внутри файла обязательно указывать: - Внутри файла обязательно указывать:
- краткое описание фичи; - краткое описание фичи;
- что именно проверять; - что именно проверять;

View File

@ -0,0 +1,31 @@
# UI-ошибки в сервер + новый сценарий персонального публичного чата
- краткое описание фичи:
- Добавлена настройка разработчика «Отправлять ошибки на сервер» (по умолчанию выключена), с локальным сохранением.
- При включенной настройке UI-ошибки отправляются в `CallDeliveryReport` с `type=ui_error` и отдельным кодом `UI_RUNTIME_ERROR`.
- После успешной отправки показывается toast: «Ошибка отправлена на сервер · <login> · <время>».
- Для вкладки `Чаты` кнопка переименована в «Новый персональный публичный чат».
- Добавлен отдельный экран создания персонального публичного чата:
- фиксированный `channelType=100`;
- ввод логина второго пользователя;
- поиск/подсказки пользователей;
- создание канала с каноническим логином из сервера;
- опциональное описание;
- предупреждение про публичность и хранение в блокчейне.
- Обновлены правила документации: имена pending-файлов и описания новых фич рекомендованы на русском.
- что именно проверять:
- В `Настройки разработчика` открыть «Отправлять ошибки на сервер», включить и сохранить.
- Сгенерировать UI-ошибку и проверить:
- появляется toast об отправке;
- запись появляется в `logs/call-delivery-events.log` с `type=ui_error`.
- На вкладке `Каналы -> Чаты` проверить новую кнопку «Новый персональный публичный чат».
- Проверить форму создания: подсказки логинов, создание с правильным регистром логина, описание и инфоблок.
- ожидаемый результат:
- UI-ошибки начинают отправляться только при включенной настройке.
- В логах сервера UI-ошибки отделяются по типу `ui_error`.
- Персональный публичный чат создается через отдельный, более понятный пользовательский сценарий.
- статус:
- pending

View File

@ -6,6 +6,7 @@
1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл: 1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md` - формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
- название `<short-feature-name>` и текст файла по возможности писать на русском языке
2. В файле указать: 2. В файле указать:
- что сделано; - что сделано;
- как проверять; - как проверять;

View File

@ -1,2 +1,2 @@
client.version=1.2.52 client.version=1.2.53
server.version=1.2.46 server.version=1.2.47

View File

@ -1,9 +1,10 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
import { renderToolbar } from './components/toolbar.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 { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js';
import { showToast } from './services/channels-ux.js';
import { import {
handleCallPushAction, handleCallPushAction,
handleIncomingCallInvite, handleIncomingCallInvite,
@ -66,6 +67,7 @@ import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
import * as channelThreadView from './pages/channel-thread-view.js'; import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-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 networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js'; import * as notificationsView from './pages/notifications-view.js';
@ -104,6 +106,7 @@ const routes = {
'channel-view': channelView, 'channel-view': channelView,
'channel-thread-view': channelThreadView, 'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView, 'add-channel-view': addChannelView,
'add-personal-public-chat-view': addPersonalPublicChatView,
'network-view': networkView, 'network-view': networkView,
'notifications-view': notificationsView, 'notifications-view': notificationsView,
}; };
@ -134,7 +137,12 @@ let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null; let uiVersionPeriodicIntervalId = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; 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(); initPwaInstallPromptHandling();
initCallUiOverlay(); initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload)); setCallDebugReporter((payload) => authService.reportClientDebug(payload));

View File

@ -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 = `
<strong class="channel-head-title">Создание персонального публичного чата</strong>
<p class="channel-head-meta">Тип канала фиксирован: персональный (100).</p>
<label for="chat-login">Введите логин другого пользователя</label>
<input id="chat-login" class="input" maxlength="20" placeholder="Например: aidar" autocomplete="off" required />
<div id="chat-login-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="chat-login-error" class="meta-muted inline-error"></div>
<label for="chat-description">Добавить описание этому публичному чату (необязательно)</label>
<textarea id="chat-description" class="input" rows="4" maxlength="400" placeholder="Короткое описание"></textarea>
<div class="meta-muted" id="chat-description-counter">0 / 200 байт</div>
<div id="chat-description-error" class="meta-muted inline-error"></div>
<div class="card meta-muted">
Публичные чаты могут просматривать любые пользователи, и сообщения сохраняются в блокчейне.
Для личной приватной переписки используйте вкладку «Личные сообщения».
</div>
<div id="chat-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button type="button" class="secondary-btn" id="cancel-create-chat">Отмена</button>
<button type="submit" class="primary-btn" id="submit-create-chat">Создать чат</button>
</div>
`;
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;
}

View File

@ -75,10 +75,10 @@ function buildAbsoluteRouteUrl(routePath = '') {
function parseThreadSelector(route) { function parseThreadSelector(route) {
const params = route?.params || {}; const params = route?.params || {};
if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) { if (params.ownerBlockchainName && params.channelName && params.messageBlockNumber) {
return { return {
short: { short: {
ownerLogin: String(params.ownerLogin || '').trim(), ownerBlockchainName: String(params.ownerBlockchainName || '').trim(),
channelName: String(params.channelName || '').trim(), channelName: String(params.channelName || '').trim(),
}, },
message: { message: {
@ -135,13 +135,11 @@ function resolveChannelDisplayName(channelSelector) {
} }
function buildBackRoute(selector) { function buildBackRoute(selector) {
const channel = selector?.channel; if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
if (channel?.ownerBlockchainName && channel.rootBlockNumber != null) {
return [ return [
'channel-view', 'channel',
encodeRoutePart(channel.ownerBlockchainName), encodeRoutePart(selector.short.ownerBlockchainName),
channel.rootBlockNumber, encodeRoutePart(selector.short.channelName),
channel.rootBlockHash,
].join('/'); ].join('/');
} }
return 'channels-list'; return 'channels-list';
@ -149,13 +147,12 @@ function buildBackRoute(selector) {
function buildThreadRouteFromTarget(target, selector) { function buildThreadRouteFromTarget(target, selector) {
if (!target) return ''; if (!target) return '';
if (selector?.short?.ownerLogin && selector?.short?.channelName) { if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
return [ return [
'channel', 'channel',
encodeRoutePart(selector.short.ownerLogin), encodeRoutePart(selector.short.ownerBlockchainName),
encodeRoutePart(selector.short.channelName), encodeRoutePart(selector.short.channelName),
target.blockNumber, target.blockNumber,
normalizeRouteHash(target.blockHash),
].join('/'); ].join('/');
} }
if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return ''; if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
@ -577,20 +574,44 @@ export function render({ navigate, route }) {
(async () => { (async () => {
try { try {
let resolvedMessage = selector.message; let resolvedMessage = selector.message;
if (selector.short?.ownerLogin && selector.short?.channelName) { if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.short.ownerLogin, 1000); const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; const allRows = [
const channel = ownChannels.find((item) => ( ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase() ...(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(); 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('Канал или сообщение не найдено.'); 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 = { resolvedMessage = {
blockchainName: ownerBch, blockchainName: ownerBch,
blockNumber: resolvedMessage.blockNumber, blockNumber: resolvedMessage.blockNumber,
blockHash: normalizeRouteHash(resolvedMessage.blockHash), blockHash: resolvedHash,
}; };
} }

View File

@ -22,7 +22,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
@ -95,29 +94,6 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') {
return `${blockchainName}:${blockNumber}:${blockHash}`; 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 = '') { function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href); const url = new URL(window.location.href);
@ -128,9 +104,9 @@ function buildAbsoluteRouteUrl(routePath = '') {
function buildSelectorFromRoute(route, channelId) { function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {}; const params = route?.params || {};
if (params.ownerLogin && params.channelName) { if (params.ownerBlockchainName && params.channelName) {
return { return {
ownerLogin: String(params.ownerLogin || '').trim(), ownerBlockchainName: String(params.ownerBlockchainName || '').trim(),
channelName: String(params.channelName || '').trim(), channelName: String(params.channelName || '').trim(),
}; };
} }
@ -157,15 +133,14 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) { function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return ''; if (!messageRef || !selector) return '';
const ownerLogin = String(selector.ownerLogin || '').trim(); const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim();
const channelName = String(selector.channelName || '').trim(); const channelName = String(selector.channelName || '').trim();
if (ownerLogin && channelName) { if (ownerBlockchainName && channelName) {
return [ return [
'channel', 'channel',
encodeRoutePart(ownerLogin), encodeRoutePart(ownerBlockchainName),
encodeRoutePart(channelName), encodeRoutePart(channelName),
messageRef.blockNumber, messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
].join('/'); ].join('/');
} }
return [ return [
@ -434,11 +409,16 @@ function mapApiMessageToPost(message, selector, localNumber) {
async function loadFromApi(route, channelId) { async function loadFromApi(route, channelId) {
let selector = buildSelectorFromRoute(route, channelId); let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerLogin && selector?.channelName) { if (selector?.ownerBlockchainName && selector?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000); const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; const allRows = [
const channel = ownChannels.find((item) => ( ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() ...(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) { if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.'); throw new Error('Канал не найден.');
@ -447,7 +427,6 @@ async function loadFromApi(route, channelId) {
ownerBlockchainName: String(channel.channel.ownerBlockchainName), ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash), channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
ownerLogin: selector.ownerLogin,
channelName: selector.channelName, channelName: selector.channelName,
}; };
} }
@ -550,9 +529,7 @@ function applyPendingScroll(screen, routeKey) {
function renderPostCard(post, { function renderPostCard(post, {
navigate, navigate,
routeKey,
selector, selector,
canWrite,
onToggleLike, onToggleLike,
onReply, onReply,
onShare, onShare,
@ -598,72 +575,57 @@ function renderPostCard(post, {
if (refKey) { if (refKey) {
card.dataset.messageKey = refKey; card.dataset.messageKey = refKey;
} }
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true; card.classList.add('is-counters-visible');
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);
if (!post.messageRef || !selector) return card; if (!post.messageRef || !selector) return card;
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'channel-message-actions'; actions.className = 'channel-message-actions';
if (canWrite) { const actionKey = makeReactionActionKey(post.messageRef);
const actionKey = makeReactionActionKey(post.messageRef); const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const likeButton = document.createElement('button'); const likeButton = document.createElement('button');
likeButton.type = 'button'; likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like'; likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked'; const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked'); if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span> <span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span> <span class="channel-action-counter">${post.likesCount || 0}</span>
`; `;
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) { if (!isLiked) {
const ok = window.confirm('Поставить лайк?'); const ok = window.confirm('Поставить лайк?');
if (!ok) return; if (!ok) return;
} }
revealCounters(); await longPressFeel(event.currentTarget, 130);
await longPressFeel(event.currentTarget, 130); likeButton.disabled = true;
likeButton.disabled = true; const labelEl = likeButton.querySelector('.channel-action-label');
const labelEl = likeButton.querySelector('.channel-action-label'); if (labelEl) labelEl.textContent = 'Лайк...';
if (labelEl) labelEl.textContent = 'Лайк...'; await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); });
});
const replyButton = document.createElement('button'); const replyButton = document.createElement('button');
replyButton.type = 'button'; replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply'; replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = ` replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span> <span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span> <span class="channel-action-label">Ответить</span>
`; <span class="channel-action-counter">${post.repliesCount || 0}</span>
replyButton.addEventListener('click', (event) => { `;
animatePress(event.currentTarget); replyButton.addEventListener('click', (event) => {
revealCounters(); animatePress(event.currentTarget);
openReplyModal({ openReplyModal({
navigate, navigate,
onSubmit: async (text) => onReply(post.messageRef, text), onSubmit: async (text) => onReply(post.messageRef, text),
});
}); });
actions.append(likeButton, replyButton); });
} actions.append(likeButton, replyButton);
const openThreadButton = document.createElement('button'); const openThreadButton = document.createElement('button');
openThreadButton.type = 'button'; openThreadButton.type = 'button';
@ -671,11 +633,9 @@ function renderPostCard(post, {
openThreadButton.innerHTML = ` openThreadButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">#</span> <span class="channel-action-icon" aria-hidden="true">#</span>
<span class="channel-action-label">Тред</span> <span class="channel-action-label">Тред</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`; `;
openThreadButton.addEventListener('click', (event) => { openThreadButton.addEventListener('click', (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector); const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route); if (route) navigate(route);
}); });
@ -690,7 +650,6 @@ function renderPostCard(post, {
shareButton.addEventListener('click', async (event) => { shareButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector); const route = buildThreadRoute(post.messageRef, selector);
await onShare(route); await onShare(route);
}); });
@ -741,9 +700,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
channelData.posts.forEach((post) => { channelData.posts.forEach((post) => {
const row = renderPostCard(post, { const row = renderPostCard(post, {
navigate, navigate,
routeKey,
selector: channelData.selector, selector: channelData.selector,
canWrite: channelData.isOwnChannel,
onToggleLike: handlers.onToggleLike, onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply, onReply: handlers.onReply,
onShare: handlers.onShare, onShare: handlers.onShare,

View File

@ -44,17 +44,11 @@ function normalizeLoginInput(value) {
function buildChannelRouteFromSummary(summary, fallbackId) { function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = summary?.channel?.ownerBlockchainName; 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(); const channelName = String(summary?.channel?.channelName || '').trim();
if (ownerLogin && channelName) { if (ownerBch && channelName) {
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(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 = '') { function avatarLetterFromName(name = '') {
@ -468,7 +462,8 @@ function openChannelFinderModal({ navigate }) {
openBtn.textContent = 'Просмотреть'; openBtn.textContent = 'Просмотреть';
openBtn.addEventListener('click', () => { openBtn.addEventListener('click', () => {
close(); 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'; row.style.display = 'flex';
@ -487,11 +482,19 @@ function openChannelFinderModal({ navigate }) {
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
const needle = String(filterChannel || '').trim().toLowerCase(); const needle = String(filterChannel || '').trim().toLowerCase();
const channels = rows const channels = rows
.map((item) => String(item?.channel?.channelName || '').trim()) .map((item) => ({
.filter(Boolean) ownerBlockchainName: String(item?.channel?.ownerBlockchainName || '').trim(),
.filter((name) => !needle || name.toLowerCase().includes(needle)) channelName: String(item?.channel?.channelName || '').trim(),
}))
.filter((item) => !!item.channelName)
.filter((item) => !needle || item.channelName.toLowerCase().includes(needle))
.slice(0, 200) .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); renderChannelRows(channels);
}; };
@ -550,7 +553,7 @@ function openChannelFinderModal({ navigate }) {
function mapMockGroups() { function mapMockGroups() {
const mapRow = (channel) => ({ const mapRow = (channel) => ({
...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' tabCategory: channel.kind === 'own'
? 'my' ? 'my'
: channel.kind === 'own-personal' : channel.kind === 'own-personal'
@ -737,7 +740,7 @@ function renderDemoFallback(container, navigate, error, onRetry) {
<span class="channel-row-time"></span> <span class="channel-row-time"></span>
</div> </div>
`; `;
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); list.append(row);
}); });
@ -1012,7 +1015,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
controls.append(menuButton, time, count); controls.append(menuButton, time, count);
row.append(avatar, main, controls); 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); list.append(row);
}); });
@ -1031,9 +1034,9 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
} }
if (tab === 'dialogs') { if (tab === 'dialogs') {
button.textContent = 'Новый персональный канал'; button.textContent = 'Новый персональный публичный чат';
button.className = baseClass; button.className = baseClass;
button.onclick = () => navigate('add-channel-view'); button.onclick = () => navigate('add-personal-public-chat-view');
return; return;
} }

View File

@ -1,5 +1,9 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, state } from '../state.js'; import { addAppLogEntry, authService, state } from '../state.js';
import {
isClientErrorReportingEnabled,
setClientErrorReportingEnabled,
} from '../services/client-error-reporter.js';
import { import {
canInstallPwa, canInstallPwa,
isStandalonePwaMode, isStandalonePwaMode,
@ -209,6 +213,37 @@ function showClientUpdateHelp() {
); );
} }
function openUiErrorReportingModal() {
const root = document.getElementById('modal-root');
if (!root) return;
const enabled = isClientErrorReportingEnabled();
root.innerHTML = `
<div class="modal" id="settings-ui-error-reporting-modal">
<div class="modal-card stack">
<h3 class="modal-title">Отправка UI-ошибок на сервер</h3>
<p class="meta-muted">Для разработчиков: при включении ошибки интерфейса будут автоматически отправляться на сервер для диагностики.</p>
<label class="row wrap-row" for="ui-error-reporting-toggle">
<input id="ui-error-reporting-toggle" type="checkbox" ${enabled ? 'checked' : ''} />
<span>Отправлять ошибки на сервер</span>
</label>
<div class="form-actions-grid">
<button class="secondary-btn" id="ui-error-reporting-cancel" type="button">Отмена</button>
<button class="primary-btn" id="ui-error-reporting-save" type="button">Сохранить</button>
</div>
</div>
</div>
`;
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 }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -225,6 +260,7 @@ export function render({ navigate }) {
card.innerHTML = ` card.innerHTML = `
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button> <button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button> <button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button> <button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button> <button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button> <button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
@ -237,6 +273,7 @@ export function render({ navigate }) {
const uploadAvatarBtn = card.querySelector('#settings-upload-avatar'); const uploadAvatarBtn = card.querySelector('#settings-upload-avatar');
const forceUpdateBtn = card.querySelector('#settings-force-ui-update'); const forceUpdateBtn = card.querySelector('#settings-force-ui-update');
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help'); const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
appLogBtn?.addEventListener('click', () => navigate('app-log-view')); appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view')); diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
@ -248,6 +285,7 @@ export function render({ navigate }) {
}); });
}); });
forceUpdateHelpBtn?.addEventListener('click', showClientUpdateHelp); forceUpdateHelpBtn?.addEventListener('click', showClientUpdateHelp);
uiErrorReportingBtn?.addEventListener('click', openUiErrorReportingModal);
forceUpdateBtn?.addEventListener('click', async () => { forceUpdateBtn?.addEventListener('click', async () => {
forceUpdateBtn.disabled = true; forceUpdateBtn.disabled = true;

View File

@ -51,25 +51,24 @@ export function getRoute() {
} }
if (pageId === 'channel') { if (pageId === 'channel') {
// Новый короткий формат: // Короткий формат:
// #/channel/{login}/{channelName} // #/channel/{ownerBlockchainName}/{channelName}
// #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash} // #/channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
const ownerLogin = decodePart(segments[1] || ''); const ownerBlockchainName = decodePart(segments[1] || '');
const channelName = decodePart(segments[2] || ''); const channelName = decodePart(segments[2] || '');
const messageBlockNumber = segments[3] || ''; const messageBlockNumber = segments[3] || '';
const messageBlockHash = segments[4] || '';
if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) { if (ownerBlockchainName && channelName && messageBlockNumber) {
return { return {
pageId: 'channel-thread-view', pageId: 'channel-thread-view',
params: { params: {
ownerLogin, ownerBlockchainName,
channelName, channelName,
messageBlockNumber, messageBlockNumber,
messageBlockHash, messageBlockHash: '',
// поддержка старого контракта страницы треда // поддержка старого контракта страницы треда
messageBlockchainName: '', messageBlockchainName: '',
channelOwnerBlockchainName: '', channelOwnerBlockchainName: ownerBlockchainName,
channelRootBlockNumber: '', channelRootBlockNumber: '',
channelRootBlockHash: '', channelRootBlockHash: '',
}, },
@ -79,7 +78,7 @@ export function getRoute() {
return { return {
pageId: 'channel-view', pageId: 'channel-view',
params: { params: {
ownerLogin, ownerBlockchainName,
channelName, channelName,
channelId: '', channelId: '',
}, },
@ -161,7 +160,7 @@ export function resolveToolbarActive(pageId) {
return 'profile-view'; return 'profile-view';
} }
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; 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'; if (pageId === 'user-profile-view') return 'messages-list';
return 'profile-view'; return 'profile-view';
} }

View File

@ -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() { close() {
this.ws.close(); this.ws.close();
} }

View File

@ -1,9 +1,11 @@
const MAX_CONTEXT_LEN = 2000; const MAX_CONTEXT_LEN = 2000;
const RECENT_WINDOW_MS = 5000; const RECENT_WINDOW_MS = 5000;
const UI_ERROR_REPORTING_KEY = 'shine-ui-send-errors-to-server-v1';
let transport = null; let transport = null;
let transportDepth = 0; let transportDepth = 0;
const recentFingerprints = new Map(); const recentFingerprints = new Map();
let notifySent = null;
function nowTs() { function nowTs() {
return Date.now(); return Date.now();
@ -79,6 +81,26 @@ export function setClientErrorTransport(fn) {
transport = typeof fn === 'function' ? fn : null; 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 = {}) { export async function captureClientError(details = {}) {
const payload = buildPayload(details); const payload = buildPayload(details);
if (!payload.message) return false; if (!payload.message) return false;
@ -88,13 +110,20 @@ export async function captureClientError(details = {}) {
console.error('[client-error]', payload.kind, payload.message, details.error || ''); 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; return false;
} }
try { try {
transportDepth += 1; transportDepth += 1;
await transport(payload); await transport(payload);
if (notifySent) {
try {
notifySent(payload);
} catch {
// ignore notifier errors
}
}
return true; return true;
} catch (error) { } catch (error) {
console.warn('client error transport failed', error); console.warn('client error transport failed', error);

View File

@ -3714,6 +3714,13 @@ textarea.input {
transform: none; 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) */ /* 2) Static controls with energy + glass glare (no levitation) */
.channels-screen--list .channels-tab-btn, .channels-screen--list .channels-tab-btn,
.channels-screen--list .channels-bottom-action, .channels-screen--list .channels-bottom-action,