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/`.
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
- Рекомендуемый формат имени файла: `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. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
- название `<short-feature-name>` и текст файла по возможности писать на русском языке
2. В файле указать:
- что сделано;
- как проверять;

View File

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

View File

@ -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));

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) {
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,
};
}

View File

@ -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,26 +575,13 @@ 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);
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;
@ -639,7 +603,6 @@ function renderPostCard(post, {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
@ -651,19 +614,18 @@ function renderPostCard(post, {
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
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-counter">${post.repliesCount || 0}</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
actions.append(likeButton, replyButton);
}
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
@ -671,11 +633,9 @@ function renderPostCard(post, {
openThreadButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">#</span>
<span class="channel-action-label">Тред</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
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,

View File

@ -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) {
<span class="channel-row-time"></span>
</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);
});
@ -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;
}

View File

@ -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 = `
<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 }) {
const screen = document.createElement('section');
screen.className = 'stack';
@ -225,6 +260,7 @@ export function render({ navigate }) {
card.innerHTML = `
<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-ui-error-reporting">Отправлять ошибки на сервер</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-install">Как установить PWA</button>
@ -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;

View File

@ -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';
}

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

View File

@ -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);

View File

@ -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,