UI: отправка UI-ошибок, персональный публичный чат, русские pending-файлы
This commit is contained in:
parent
e73e103ac4
commit
56a69ab683
@ -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`.
|
||||||
|
- Имена новых файлов и краткие описания фич по возможности писать на русском языке.
|
||||||
- Внутри файла обязательно указывать:
|
- Внутри файла обязательно указывать:
|
||||||
- краткое описание фичи;
|
- краткое описание фичи;
|
||||||
- что именно проверять;
|
- что именно проверять;
|
||||||
|
|||||||
@ -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
|
||||||
@ -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. В файле указать:
|
||||||
- что сделано;
|
- что сделано;
|
||||||
- как проверять;
|
- как проверять;
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.52
|
client.version=1.2.53
|
||||||
server.version=1.2.46
|
server.version=1.2.47
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
223
shine-UI/js/pages/add-personal-public-chat-view.js
Normal file
223
shine-UI/js/pages/add-personal-public-chat-view.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,26 +575,13 @@ function renderPostCard(post, {
|
|||||||
if (refKey) {
|
if (refKey) {
|
||||||
card.dataset.messageKey = 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');
|
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;
|
||||||
|
|
||||||
@ -639,7 +603,6 @@ function renderPostCard(post, {
|
|||||||
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');
|
||||||
@ -651,19 +614,18 @@ function renderPostCard(post, {
|
|||||||
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) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
revealCounters();
|
|
||||||
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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user