feat: finalize channels fixes and runtime stability

This commit is contained in:
DrygMira
2026-04-13 23:00:36 +03:00
parent 0c7d8fac02
commit a9c69e5947
46 changed files with 4654 additions and 356 deletions
+58 -9
View File
@@ -41,6 +41,7 @@ import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
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 networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
@@ -72,6 +73,7 @@ const routes = {
'chat-view': chatView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView,
'network-view': networkView,
'notifications-view': notificationsView,
@@ -141,6 +143,45 @@ window.addEventListener('unhandledrejection', (event) => {
});
});
function renderPageFailureFallback(pageId, error) {
captureClientError({
kind: 'page_render_failure',
message: error?.message || 'Page render failed',
stack: error?.stack || '',
context: {
pageId,
routeHash: window.location.hash || '',
},
});
screenEl.innerHTML = '';
const wrap = document.createElement('section');
wrap.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack channels-status';
const title = document.createElement('strong');
title.textContent = 'Не удалось отрисовать экран';
const details = document.createElement('p');
details.className = 'meta-muted';
details.textContent = `Экран: ${pageId || 'неизвестно'}. Попробуйте повторить.`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', () => renderApp());
card.append(title, details, retry);
wrap.append(card);
screenEl.append(wrap);
screenEl.classList.toggle('no-app-chrome', false);
toolbarEl.innerHTML = '';
}
function renderApp() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
@@ -162,18 +203,26 @@ function renderApp() {
currentCleanup = null;
}
screenEl.innerHTML = '';
const screen = page.render({ route, navigate });
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
try {
screenEl.innerHTML = '';
const screen = page.render({ route, navigate });
if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node');
}
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
toolbarEl.innerHTML = '';
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
} catch (error) {
console.error('[renderApp] controlled fallback', error);
renderPageFailureFallback(pageId, error);
}
}
+97 -12
View File
@@ -1,38 +1,123 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
validateChannelDisplayName,
} from '../services/channel-name-rules.js';
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
function persistCreateSuccessFlash(message) {
try {
sessionStorage.setItem(CREATE_CHANNEL_FLASH_KEY, String(message || '').trim());
} catch {
// ignore storage errors
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
title: 'Добавить канал',
leftAction: { label: '', onClick: () => navigate('channels-list') },
title: 'Создать канал',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
})
);
const form = document.createElement('form');
form.className = 'card stack';
form.innerHTML = `
<label for="channel-name">Имя канала</label>
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
<p class="channel-head-meta">Длина: от 3 до 32 символов. Название уникально во всей системе.</p>
<label for="channel-name">Название канала</label>
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
<div id="channel-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
<button type="submit" class="primary-btn">Создать</button>
<button type="submit" class="primary-btn" id="submit-create-channel">Создать</button>
</div>
`;
form.addEventListener('submit', (event) => {
event.preventDefault();
navigate('channels-list');
const inputEl = form.querySelector('#channel-name');
const errorEl = form.querySelector('#channel-create-error');
const submitEl = form.querySelector('#submit-create-channel');
const cancelEl = form.querySelector('#cancel-create-channel');
let submitInFlight = false;
const setBusy = (busy) => {
submitInFlight = !!busy;
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
inputEl.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
};
const updateValidation = () => {
const check = validateChannelDisplayName(inputEl.value);
if (!check.ok) {
errorEl.textContent = channelNameErrorText(check.code);
} else {
errorEl.textContent = '';
}
submitEl.disabled = submitInFlight || !check.ok;
return check;
};
inputEl.addEventListener('input', () => {
updateValidation();
});
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
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 check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
const channelName = normalizeChannelDisplayName(check.normalized);
await authService.addBlockCreateChannel({
login,
storagePwd,
channelName,
});
persistCreateSuccessFlash(`Канал "${channelName}" создан.`);
navigate('channels-list');
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
setBusy(false);
const checkAfterError = validateChannelDisplayName(inputEl.value);
submitEl.disabled = submitInFlight || !checkAfterError.ok;
}
});
cancelEl.addEventListener('click', () => {
navigate('channels-list');
});
screen.append(form);
if (inputEl) {
inputEl.focus();
updateValidation();
}
return screen;
}
+461
View File
@@ -0,0 +1,461 @@
import { renderHeader } from '../components/header.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const pendingReactionActions = new Set();
function logThreadRuntimeError(stage, error, context = {}) {
const message = String(error?.message || error || 'thread runtime error');
console.error(`[channel-thread-view:${stage}]`, error, context);
captureClientError({
kind: 'channels_thread_runtime',
message,
stack: error?.stack || '',
context: {
stage,
...context,
},
});
}
function encodeRoutePart(value = '') {
return encodeURIComponent(String(value));
}
function normalizeRouteHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
function normalizeMessageHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
if (/^0+$/.test(normalized)) return '';
return normalized;
}
function toSafeInt(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
}
function parseThreadSelector(route) {
const params = route?.params || {};
const blockNumber = toSafeInt(params.messageBlockNumber);
if (!params.messageBlockchainName || blockNumber == null) return null;
return {
message: {
blockchainName: String(params.messageBlockchainName),
blockNumber,
blockHash: normalizeRouteHash(params.messageBlockHash),
},
channel: {
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
},
};
}
function allFeedSummaries() {
const feed = state.channelsFeed || {};
return [
...(feed.ownedChannels || []),
...(feed.followedUsersChannels || []),
...(feed.followedChannels || []),
];
}
function resolveChannelDisplayName(channelSelector) {
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
const ownerBch = String(channelSelector.ownerBlockchainName);
const rootNo = Number(channelSelector.rootBlockNumber);
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
const found = allFeedSummaries().find((summary) => (
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
&& Number(summary?.channel?.channelRoot?.blockNumber) === rootNo
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
));
if (!found) return '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
}
function buildBackRoute(selector) {
const channel = selector?.channel;
if (channel?.ownerBlockchainName && channel.rootBlockNumber != null) {
return [
'channel-view',
encodeRoutePart(channel.ownerBlockchainName),
channel.rootBlockNumber,
channel.rootBlockHash,
].join('/');
}
return 'channels-list';
}
function buildTargetFromNode(node) {
const blockchainName = String(node?.authorBlockchainName || '').trim();
const blockNumber = Number(node?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(node?.messageRef?.blockHash);
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
return { blockchainName, blockNumber, blockHash };
}
function firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (trimmed.length > 0) return candidate;
}
return '';
}
function latestVersionText(versions) {
if (!Array.isArray(versions)) return '';
for (let i = versions.length - 1; i >= 0; i -= 1) {
const version = versions[i];
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (value) return value;
}
return '';
}
function resolveNodeText(node) {
return firstNonEmptyText(
node?.text,
node?.message,
node?.body,
latestVersionText(node?.versions),
);
}
function openReplyModal({ onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#thread-reply-text');
const errorEl = root.querySelector('#thread-reply-error');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-reply-cancel').addEventListener('click', close);
root.querySelector('#thread-reply-submit').addEventListener('click', async () => {
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
return;
}
try {
await onSubmit(text);
close();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
}
});
if (textEl) textEl.focus();
}
function renderNodeCard(node, heading, handlers) {
const card = document.createElement('article');
card.className = 'card stack thread-node-card';
const author = node?.authorLogin || 'автор';
const bch = node?.authorBlockchainName || '-';
const blockNo = node?.messageRef?.blockNumber ?? '?';
const text = resolveNodeText(node) || '(пусто)';
const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 0);
const versions = Number(node?.versionsTotal || 1);
card.innerHTML = `
<strong class="thread-node-heading">${heading}</strong>
<p class="thread-node-meta">${author} (${bch}) - #${blockNo}</p>
<p class="thread-node-body">${text}</p>
<p class="thread-node-stats">Лайки: ${likes}, ответы: ${replies}, версий: ${versions}</p>
`;
const target = buildTargetFromNode(node);
if (!target || !handlers) return card;
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
const actionKey = makeReactionActionKey(target);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const isLiked = getMessageReactionState(target) === 'liked';
const actions = document.createElement('div');
actions.className = 'thread-node-actions';
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'secondary-btn thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async () => {
if (isPending) return;
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
logThreadRuntimeError('like_click', error, {
action: isLiked ? 'unlike' : 'like',
targetBlockchainName: target?.blockchainName || '',
targetBlockNumber: target?.blockNumber,
});
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
}
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.textContent = 'Ответить';
replyButton.addEventListener('click', () => {
openReplyModal({
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
actions.append(likeButton, replyButton);
card.append(actions);
return card;
}
function renderDescendants(items, handlers, depth = 0) {
const wrap = document.createElement('div');
wrap.className = 'stack';
const normalized = Array.isArray(items) ? items : [];
normalized.forEach((branch, index) => {
try {
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers);
row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4)));
wrap.append(row);
if (Array.isArray(branch?.children) && branch.children.length) {
wrap.append(renderDescendants(branch.children, handlers, depth + 1));
}
} catch (error) {
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
}
});
return wrap;
}
export function render({ navigate, route }) {
const selector = parseThreadSelector(route);
const backRoute = buildBackRoute(selector);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
const userIndicator = document.createElement('div');
userIndicator.className = 'card channels-user-chip';
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
const channelIndicator = document.createElement('div');
channelIndicator.className = 'card channels-user-chip';
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const rerender = () => {
try {
const current = document.querySelector('section.channels-screen--thread');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, {
routeHash: window.location.hash,
});
}
};
const showStatus = (message) => {
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
return;
}
statusBox.textContent = message;
statusBox.style.display = '';
};
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
return { login, storagePwd };
};
const rereadThread = async () => {
if (!selector) return;
await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
};
const handlers = {
onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
if (pendingReactionActions.has(actionKey)) return;
pendingReactionActions.add(actionKey);
rerender();
try {
const { login, storagePwd } = requireSigningSession();
if (action === 'unlike') {
await authService.addBlockUnlike({ login, storagePwd, message: target });
} else {
await authService.addBlockLike({ login, storagePwd, message: target });
}
await rereadThread();
showStatus('');
} catch (error) {
logThreadRuntimeError('toggle_like', error, {
action,
targetBlockchainName: target?.blockchainName || '',
targetBlockNumber: target?.blockNumber,
});
throw error;
} finally {
pendingReactionActions.delete(actionKey);
rerender();
}
},
onReply: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
await rereadThread();
showStatus('');
rerender();
},
onActionError: (error, action) => {
const fallback = action === 'unlike'
? 'Не удалось убрать лайк.'
: 'Не удалось поставить лайк.';
showStatus(toUserMessage(error, fallback));
},
};
screen.append(
renderHeader({
title: 'Тред',
leftAction: { label: '<', onClick: () => navigate(backRoute) },
})
);
screen.append(userIndicator, channelIndicator, statusBox);
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
return screen;
}
const loading = document.createElement('div');
loading.className = 'card meta-muted';
loading.textContent = 'Загрузка треда...';
screen.append(loading);
(async () => {
try {
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
loading.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
const focus = payload?.focus || null;
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
const summary = document.createElement('div');
summary.className = 'card thread-summary';
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
screen.append(summary);
if (ancestors.length) {
const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'Предыдущие сообщения';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers));
});
screen.append(ancestorsWrap);
}
if (focus) {
const focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'Текущее сообщение';
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers));
screen.append(focusWrap);
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы';
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers));
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ответов пока нет.';
descendantsWrap.append(empty);
}
screen.append(descendantsWrap);
} catch (error) {
loading.remove();
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
screen.append(failed);
}
})();
return screen;
}
+505 -72
View File
@@ -1,11 +1,169 @@
import { renderHeader } from '../components/header.js';
import { channelPosts, channels } from '../mock-data.js';
import { addLocalChannelPost, authService, getLocalChannelPosts, state } from '../state.js';
import {
addLocalChannelPost,
authService,
getLocalChannelPosts,
getMessageReactionState,
setMessageReactionState,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const ZERO64 = '0'.repeat(64);
const pendingReactionActions = new Set();
function isChannelsDemoMode() {
try {
const qs = new URLSearchParams(window.location.search);
if (qs.get('channelsDemo') === '1') return true;
return localStorage.getItem('shine-channels-demo') === '1';
} catch {
return false;
}
}
function encodeRoutePart(value = '') {
return encodeURIComponent(String(value));
}
function normalizeRouteHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
function normalizeMessageHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
if (/^0+$/.test(normalized)) return '';
return normalized;
}
function toSafeInt(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
}
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
return {
ownerBlockchainName: String(params.ownerBlockchainName),
channelRootBlockNumber: rootBlockNumber,
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
};
}
}
const summary = channelId ? state.channelsIndex[channelId] : null;
if (!summary) return null;
return {
ownerBlockchainName: summary.channel?.ownerBlockchainName,
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
channelRootBlockHash: normalizeRouteHash(summary.channel?.channelRoot?.blockHash),
};
}
function localPostsKey(selector, channelId) {
if (selector?.ownerBlockchainName && selector?.channelRootBlockNumber != null) {
return `${selector.ownerBlockchainName}:${selector.channelRootBlockNumber}`;
}
return channelId || '';
}
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
encodeRoutePart(selector.ownerBlockchainName),
selector.channelRootBlockNumber,
normalizeRouteHash(selector.channelRootBlockHash),
].join('/');
}
function firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (trimmed.length > 0) return candidate;
}
return '';
}
function latestVersionText(versions) {
if (!Array.isArray(versions)) return '';
for (let i = versions.length - 1; i >= 0; i -= 1) {
const version = versions[i];
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (value) return value;
}
return '';
}
function resolveMessageText(message) {
return firstNonEmptyText(
message?.text,
message?.message,
message?.body,
latestVersionText(message?.versions),
);
}
function mapApiMessageToPost(message, selector) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim();
const hasRef = !!(messageBch && blockNumber != null && blockHash);
const resolvedText = resolveMessageText(message);
const messageRef = hasRef
? {
blockchainName: messageBch,
blockNumber,
blockHash,
}
: null;
if (messageRef) {
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
}
return {
title: `${message?.authorLogin || 'автор'} - #${blockNumber ?? '?'}`,
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
};
}
function findMockChannel(channelId) {
const channel = channels.find((c) => c.id === channelId) || channels[0];
const fallback = channels[0] || {
id: 'ch0',
name: 'Неизвестный канал',
description: 'Описание отсутствует',
ownerName: 'неизвестно',
ownerLogin: '',
displayName: 'неизвестно/Неизвестный канал',
};
const channel = channels.find((c) => c.id === channelId) || fallback;
return {
channel,
posts: [
@@ -13,33 +171,62 @@ function findMockChannel(channelId) {
...getLocalChannelPosts(channelId),
],
isOwnChannel: channel.ownerLogin === '@shine.alex',
selector: null,
localKey: channelId,
};
}
function mapApiMessageToPost(message) {
return {
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
body: message.text || '(пусто)',
function openReplyModal({ onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#reply-text');
const errorEl = root.querySelector('#reply-error');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#reply-cancel').addEventListener('click', close);
root.querySelector('#reply-submit').addEventListener('click', async () => {
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
return;
}
try {
await onSubmit(text);
close();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
}
});
if (textEl) textEl.focus();
}
function renderPostCard(post) {
const card = document.createElement('article');
card.className = 'card stack';
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
return card;
}
function openAddMessageModal({ channelId, channelName, onSubmit }) {
function openAddMessageModal({ channelName, onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">Новое сообщение в канал</h3>
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted"># ${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Введите текст сообщения"></textarea>
<div class="meta-muted" id="channel-message-error" style="min-height:18px;"></div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
</div>
@@ -54,110 +241,314 @@ function openAddMessageModal({ channelId, channelName, onSubmit }) {
};
root.querySelector('#channel-message-cancel').addEventListener('click', close);
root.querySelector('#channel-message-submit').addEventListener('click', () => {
const body = textEl.value.trim();
root.querySelector('#channel-message-submit').addEventListener('click', async () => {
const body = String(textEl?.value || '').trim();
if (!body) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
onSubmit({
title: `${state.session.login || 'Вы'} • сейчас`,
body,
});
close();
try {
await onSubmit({
title: `${state.session.login || 'вы'} - сейчас`,
body,
});
close();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
});
if (textEl) textEl.focus();
}
function renderBody(screen, navigate, channelId, channelData) {
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const stats = document.createElement('p');
stats.className = 'channel-message-stats';
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
card.innerHTML = `<strong class="channel-message-title">${post.title}</strong><p class="channel-message-body">${post.body}</p>`;
card.append(stats);
if (!post.messageRef || !selector) return card;
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const actions = document.createElement('div');
actions.className = 'channel-message-actions';
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'secondary-btn channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async () => {
if (isPending) return;
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'secondary-btn channel-action-reply';
replyButton.textContent = 'Ответить';
replyButton.addEventListener('click', () => {
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
openThreadButton.className = 'secondary-btn channel-action-thread';
openThreadButton.textContent = 'Открыть тред';
openThreadButton.addEventListener('click', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
actions.append(likeButton, replyButton, openThreadButton);
card.append(actions);
return card;
}
function renderBody(screen, navigate, channelData, handlers) {
const head = document.createElement('div');
head.className = 'card';
head.className = 'card channel-head-card';
head.innerHTML = `
<strong># ${channelData.channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
<strong class="channel-head-title">${channelData.channel.displayName || channelData.channel.name}</strong>
<p class="channel-head-meta">${channelData.channel.description}</p>
<p class="channel-head-meta">Владелец: ${channelData.channel.ownerName}</p>
<p class="channel-note">Состояние лайка обновляется после подтверждённого reread с сервера.</p>
`;
const actionButton = document.createElement('button');
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
let followLimit = null;
const feed = document.createElement('div');
feed.className = 'stack';
feed.className = 'stack channel-feed';
channelData.posts.forEach((post) => {
feed.append(renderPostCard(post));
feed.append(renderPostCard(post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
}));
});
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', () => {
openAddMessageModal({
channelId,
channelName: channelData.channel.name,
onSubmit: (post) => {
addLocalChannelPost(channelId, post);
channelData.posts.push(post);
feed.append(renderPostCard(post));
},
onSubmit: async (post) => handlers.onAddPost(post),
});
});
} else {
followLimit = document.createElement('p');
followLimit.className = 'channel-note';
followLimit.textContent = 'Отписка удаляет только эту подписку на канал.';
actionButton.addEventListener('click', handlers.onUnfollowChannel);
}
const backButton = document.createElement('button');
backButton.className = 'secondary-btn';
backButton.textContent = 'Назад к списку';
backButton.className = 'secondary-btn channel-back-btn';
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
if (followLimit) {
screen.append(head, followLimit, actionButton, feed, backButton);
return;
}
screen.append(head, actionButton, feed, backButton);
}
async function loadFromApi(channelId) {
const summary = state.channelsIndex[channelId];
if (!summary) return null;
async function loadFromApi(route, channelId) {
const selector = buildSelectorFromRoute(route, channelId);
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
const selector = {
ownerBlockchainName: summary.channel?.ownerBlockchainName,
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
channelRootBlockHash: summary.channel?.channelRoot?.blockHash,
};
if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null;
const payload = await authService.getChannelMessages(selector, 200, 'asc');
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
const localKey = localPostsKey(selector, channelId);
const posts = [
...(payload.messages || []).map(mapApiMessageToPost),
...getLocalChannelPosts(channelId),
...(payload.messages || []).map((message) => mapApiMessageToPost(message, selector)),
...getLocalChannelPosts(localKey),
];
return {
channel: {
name: payload.channel?.channelName || summary.channel?.channelName || 'unknown',
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${payload.channel?.ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
ownerName: payload.channel?.ownerLogin || 'неизвестно',
},
posts,
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
localKey,
};
}
function renderLoadError(screen, navigate, message, onRetry) {
const card = document.createElement('div');
card.className = 'card stack channels-status';
card.innerHTML = `
<strong>Не удалось загрузить канал</strong>
<p class="meta-muted">${message || 'Проверьте подключение к серверу и повторите попытку.'}</p>
`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', onRetry);
const back = document.createElement('button');
back.type = 'button';
back.className = 'secondary-btn';
back.textContent = 'Назад к каналам';
back.addEventListener('click', () => navigate('channels-list'));
card.append(retry, back);
screen.append(card);
}
function renderDemoFallback(screen, navigate, channelId, error) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные канала с сервера недоступны. Показан мок-канал, потому что включен channelsDemo.</p>
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
`;
screen.append(info);
renderBody(screen, navigate, findMockChannel(channelId || 'ch1'), {
onToggleLike: async () => {},
onReply: async () => {},
onAddPost: async (post) => {
addLocalChannelPost(channelId || 'ch1', post);
},
onUnfollowChannel: () => {},
});
}
export function render({ navigate, route }) {
const channelId = route.params.channelId || 'ch1';
const channelId = route.params.channelId || '';
const routeSelector = buildSelectorFromRoute(route, channelId);
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack channels-screen channels-screen--channel';
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
const fallbackName = channels.find((c) => c.id === channelId)?.name || 'Канал';
const titleFromIndex = state.channelsIndex[channelId]?.channel?.channelName;
const ownerFromIndex = state.channelsIndex[channelId]?.channel?.ownerLogin;
const titleFromIndexDisplay = (ownerFromIndex && titleFromIndex) ? `${ownerFromIndex}/${titleFromIndex}` : titleFromIndex;
const titleFromRoute = route.params.ownerBlockchainName ? String(route.params.ownerBlockchainName) : '';
const headerTitle = `Канал: ${titleFromIndexDisplay || titleFromRoute || fallbackName}`;
const userIndicator = document.createElement('div');
userIndicator.className = 'card channels-user-chip';
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
};
const showStatus = (message) => {
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
return;
}
statusBox.textContent = message;
statusBox.style.display = '';
};
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
throw new Error('Сессия недействительна. Выполните вход заново.');
}
return { login, storagePwd };
};
const rereadChannel = async () => {
await loadFromApi(route, channelId);
};
const onToggleLike = async (messageRef, action) => {
const actionKey = makeReactionActionKey(messageRef);
if (!actionKey) {
throw new Error('Некорректная ссылка на сообщение для реакции.');
}
if (pendingReactionActions.has(actionKey)) return;
pendingReactionActions.add(actionKey);
rerender();
try {
const { login, storagePwd } = requireSigningSession();
if (action === 'unlike') {
await authService.addBlockUnlike({ login, storagePwd, message: messageRef });
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
await rereadChannel();
showStatus('');
} finally {
pendingReactionActions.delete(actionKey);
rerender();
}
};
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
await rereadChannel();
rerender();
};
const onAddPost = async (post) => {
const { login, storagePwd } = requireSigningSession();
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockTextPost({
login,
storagePwd,
channel: routeSelector,
text: post?.body || '',
});
await rereadChannel();
rerender();
};
screen.append(
renderHeader({
title: headerTitle,
leftAction: { label: '', onClick: () => navigate('channels-list') },
leftAction: { label: '<', onClick: () => navigate('channels-list') },
})
);
screen.append(userIndicator, statusBox);
const loading = document.createElement('div');
loading.className = 'card meta-muted';
@@ -166,18 +557,60 @@ export function render({ navigate, route }) {
(async () => {
try {
const apiData = await loadFromApi(channelId);
const apiData = await loadFromApi(route, channelId);
loading.remove();
if (apiData) {
renderBody(screen, navigate, channelId, apiData);
renderBody(screen, navigate, apiData, {
onToggleLike: async (messageRef, action) => {
try {
await onToggleLike(messageRef, action);
} catch (error) {
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
}
},
onReply: async (messageRef, text) => {
try {
await onReply(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
}
},
onAddPost: async (post) => {
try {
await onAddPost(post);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
onUnfollowChannel: async () => {
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
});
navigate('channels-list');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
},
});
} catch (error) {
loading.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, channelId, error);
return;
}
} catch {
// fallback to mock below
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
}
loading.remove();
renderBody(screen, navigate, channelId, findMockChannel(channelId));
})();
return screen;
+398 -61
View File
@@ -1,31 +1,36 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
function openSimpleSubscribeModal(kindLabel) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-subscribe-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">${kindLabel}</h3>
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
<input id="subscribe-input" class="input" placeholder="@login или #канал" />
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<button class="secondary-btn" id="sub-cancel">Отмена</button>
<button class="primary-btn" id="sub-submit">Подписаться</button>
</div>
</div>
</div>
`;
function isChannelsDemoMode() {
try {
const qs = new URLSearchParams(window.location.search);
if (qs.get('channelsDemo') === '1') return true;
return localStorage.getItem('shine-channels-demo') === '1';
} catch {
return false;
}
}
const close = () => {
root.innerHTML = '';
};
function normalizeHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
root.querySelector('#sub-cancel').addEventListener('click', close);
root.querySelector('#sub-submit').addEventListener('click', close);
function encodeRoutePart(value = '') {
return encodeURIComponent(String(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/${fallbackId}`;
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
}
function initialsFromName(name = '') {
@@ -33,30 +38,202 @@ function initialsFromName(name = '') {
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
}
function allFeedSummaries() {
const feed = state.channelsFeed || {};
return [
...(feed.ownedChannels || []),
...(feed.followedUsersChannels || []),
...(feed.followedChannels || []),
];
}
function resolveChannelTargetFromInput(rawInput) {
const input = String(rawInput || '').trim();
if (!input) return null;
const bySelector = input.match(/^([A-Za-z0-9._-]+-\d+)\s*[:/]\s*(\d+)\s*[:/]\s*([A-Fa-f0-9]{1,64})$/);
if (bySelector) {
return {
ownerBlockchainName: bySelector[1],
rootBlockNumber: Number(bySelector[2]),
rootBlockHash: normalizeHash(bySelector[3]),
};
}
const summaries = allFeedSummaries();
const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/);
if (byOwnerAndName) {
const owner = byOwnerAndName[1].trim().toLowerCase();
const channelName = byOwnerAndName[2].trim().toLowerCase();
const match = summaries.find((summary) => (
String(summary?.channel?.ownerLogin || '').toLowerCase() === owner
&& String(summary?.channel?.channelName || '').toLowerCase() === channelName
));
if (!match) return null;
return {
ownerBlockchainName: match.channel?.ownerBlockchainName,
rootBlockNumber: Number(match.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHash(match.channel?.channelRoot?.blockHash),
};
}
const byNameOnly = input.replace(/^#/, '').trim().toLowerCase();
if (!byNameOnly) return null;
const matches = summaries.filter((summary) => (
String(summary?.channel?.channelName || '').toLowerCase() === byNameOnly
));
if (matches.length !== 1) return null;
return {
ownerBlockchainName: matches[0].channel?.ownerBlockchainName,
rootBlockNumber: Number(matches[0].channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHash(matches[0].channel?.channelRoot?.blockHash),
};
}
function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) {
const targetHint = kind === 'channel'
? '<p class="meta-muted">Цель канала: owner/channel или bch:number:hash.</p>'
: '<p class="meta-muted">Цель пользователя: @login.</p>';
const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться');
const placeholder = kind === 'channel' ? '@owner/#channel или bch:number:hash' : '@login';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-subscribe-modal">
<div class="modal-card stack">
<h3 class="modal-title">${kindLabel}</h3>
${targetHint}
<label class="meta-muted" for="subscribe-input">Идентификатор</label>
<input id="subscribe-input" class="input" placeholder="${placeholder}" />
<div id="subscribe-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="sub-cancel" type="button">Отмена</button>
<button class="primary-btn" id="sub-submit" type="button">${submitText}</button>
</div>
</div>
</div>
`;
const inputEl = root.querySelector('#subscribe-input');
const errorEl = root.querySelector('#subscribe-error');
const submitEl = root.querySelector('#sub-submit');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#sub-cancel').addEventListener('click', close);
submitEl.addEventListener('click', async () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
const value = String(inputEl?.value || '').trim();
if (!login || !storagePwd) {
errorEl.textContent = 'Сессия недействительна. Выполните вход заново.';
return;
}
if (!value) {
errorEl.textContent = 'Введите идентификатор.';
return;
}
submitEl.disabled = true;
errorEl.textContent = '';
try {
if (kind === 'user') {
await authService.addBlockFollowUser({
login,
targetLogin: value.replace(/^@+/, ''),
storagePwd,
unfollow,
});
} else if (kind === 'channel') {
const target = resolveChannelTargetFromInput(value);
if (!target?.ownerBlockchainName || !Number.isFinite(target.rootBlockNumber)) {
throw new Error('Канал не найден. Используйте owner/channel или bch:number:hash.');
}
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: target.ownerBlockchainName,
targetBlockNumber: target.rootBlockNumber,
targetBlockHashHex: target.rootBlockHash,
unfollow,
});
} else {
throw new Error('Неподдерживаемый тип подписки');
}
close();
if (typeof onSuccess === 'function') onSuccess();
} catch (error) {
errorEl.textContent = toUserMessage(error, `${submitText} не удалось.`);
submitEl.disabled = false;
}
});
if (inputEl) inputEl.focus();
}
function mapMockGroups() {
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
const mapRow = (channel) => ({
...channel,
route: `channel-view/${channel.id}`,
});
const ownChannels = mockChannels
.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
.map(mapRow);
const followedUserChannels = mockChannels
.filter((channel) => channel.kind === 'followed-user-channel')
.map(mapRow);
const subscribedChannels = mockChannels
.filter((channel) => channel.kind === 'subscribed')
.map(mapRow);
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
}
function mapApiChannelRow(summary, bucketKey, idx, index) {
const rowId = `${bucketKey}-${idx}`;
index[rowId] = summary;
const ownerLogin = summary.channel?.ownerLogin || 'неизвестно';
const channelName = summary.channel?.channelName || '(без названия)';
const displayName = `${ownerLogin}/${channelName}`;
return {
id: rowId,
source: 'api',
ownerName: summary.channel?.ownerLogin || 'unknown',
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
name: summary.channel?.channelName || '(без имени)',
description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`,
route: buildChannelRouteFromSummary(summary, rowId),
ownerName: ownerLogin,
initials: initialsFromName(channelName || ownerLogin || '?'),
name: channelName,
displayName,
description: `bch=${summary.channel?.ownerBlockchainName || '-'}`,
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '',
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '-',
messagesCount: summary.messagesCount || 0,
};
}
function pullCreateSuccessFlash() {
try {
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
if (value) sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY);
return value;
} catch {
return '';
}
}
function mapApiFeed(feed) {
const index = {};
@@ -77,40 +254,48 @@ function mapApiFeed(feed) {
function renderChannelRow(channel, navigate) {
const row = document.createElement('article');
row.className = 'list-item';
row.className = 'channel-row';
row.innerHTML = `
<div class="avatar">${channel.initials}</div>
<div>
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</p>
<div class="channel-row-main">
<strong class="channel-row-title">${channel.displayName || channel.name}</strong>
<p class="channel-row-description">${channel.description}</p>
<p class="channel-row-message">${channel.lastMessage}</p>
<p class="channel-row-owner">Владелец: ${channel.ownerName}</p>
</div>
<div style="display:grid; justify-items:end; gap:6px;">
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
<span class="meta-muted">${channel.time}</span>
<span class="unread">${channel.messagesCount}</span>
<div class="channel-row-meta">
<span class="channel-row-kind">Канал</span>
<span class="channel-row-time">${channel.time}</span>
<span class="unread channel-row-count">${channel.messagesCount}</span>
</div>
`;
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`));
return row;
}
function renderSection(title, items, navigate) {
const wrap = document.createElement('section');
wrap.className = 'stack';
wrap.className = 'stack channels-section';
const header = document.createElement('h3');
header.className = 'section-title';
header.textContent = title;
wrap.append(header);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'channels-list-empty';
empty.textContent = 'Пока пусто.';
wrap.append(empty);
return wrap;
}
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
return wrap;
}
function renderGroupedList(screen, navigate, groups) {
function renderGroupedList(container, navigate, groups) {
const listWrap = document.createElement('div');
listWrap.className = 'channels-scroll-wrap';
@@ -123,17 +308,17 @@ function renderGroupedList(screen, navigate, groups) {
dividerOne.className = 'channels-divider';
list.append(dividerOne);
list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate));
list.append(renderSection('Каналы пользователей, на которых я подписан', groups.followedUserChannels, navigate));
const dividerTwo = document.createElement('hr');
dividerTwo.className = 'channels-divider';
list.append(dividerTwo);
list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate));
list.append(renderSection('Каналы, на которые я подписан', groups.subscribedChannels, navigate));
const addChannelButton = document.createElement('button');
addChannelButton.className = 'primary-btn';
addChannelButton.textContent = 'Добавить канал';
addChannelButton.className = 'primary-btn channels-bottom-action';
addChannelButton.textContent = 'Создать канал';
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
list.append(addChannelButton);
@@ -142,43 +327,195 @@ function renderGroupedList(screen, navigate, groups) {
scrollHint.className = 'channels-scroll-hint';
listWrap.append(list, scrollHint);
screen.append(listWrap);
container.append(listWrap);
}
async function loadFeedAndRender(screen, navigate) {
function renderErrorState(container, error, onRetry) {
const errCard = document.createElement('div');
errCard.className = 'card stack channels-status';
const title = document.createElement('strong');
title.textContent = 'Не удалось загрузить каналы';
const details = document.createElement('p');
details.className = 'meta-muted';
details.textContent = toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.');
const retry = document.createElement('button');
retry.className = 'primary-btn';
retry.type = 'button';
retry.textContent = 'Повторить';
retry.addEventListener('click', onRetry);
errCard.append(title, details, retry);
container.append(errCard);
}
function renderDemoFallback(container, navigate, error, onRetry) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.</p>
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
`;
const retry = document.createElement('button');
retry.className = 'secondary-btn';
retry.type = 'button';
retry.textContent = 'Повторить запрос к серверу';
retry.addEventListener('click', onRetry);
info.append(retry);
container.append(info);
renderGroupedList(container, navigate, mapMockGroups());
}
async function loadFeedAndRender(container, navigate) {
container.innerHTML = '';
const status = document.createElement('div');
status.className = 'card meta-muted';
status.textContent = 'Загрузка каналов с сервера...';
screen.append(status);
status.textContent = 'Загрузка каналов...';
container.append(status);
try {
if (!state.session.login) throw new Error('not_authorized');
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed);
setChannelsFeed(feed, groups.index);
status.remove();
renderGroupedList(screen, navigate, groups);
} catch {
container.innerHTML = '';
renderGroupedList(container, navigate, groups);
} catch (error) {
setChannelsFeed(null, {});
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
renderGroupedList(screen, navigate, mapMockGroups());
container.innerHTML = '';
if (isChannelsDemoMode()) {
renderDemoFallback(container, navigate, error, () => loadFeedAndRender(container, navigate));
return;
}
renderErrorState(container, error, () => loadFeedAndRender(container, navigate));
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack channels-screen channels-screen--list';
const createSuccessFlash = pullCreateSuccessFlash();
const hero = document.createElement('div');
hero.className = 'card channels-hero';
hero.innerHTML = `
<div class="channels-hero-emblem" aria-hidden="true"></div>
<div class="channels-hero-copy">
<p class="channels-hero-kicker">SHiNE</p>
<p class="channels-hero-title">Каналы</p>
<p class="channels-hero-subtitle">Ленты, треды и подписки в одном экране.</p>
</div>
`;
const currentUser = document.createElement('div');
currentUser.className = 'card channels-user-chip';
currentUser.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
let flashCard = null;
if (createSuccessFlash) {
flashCard = document.createElement('div');
flashCard.className = 'card status-line is-available';
flashCard.textContent = createSuccessFlash;
}
const help = document.createElement('div');
help.className = 'card stack channels-help-card';
help.innerHTML = `
<strong>Быстрый ручной тест</strong>
<p class="meta-muted">
1) Создайте пользователей A и B.<br />
2) Под A создайте 2 канала и сообщения.<br />
3) Под B проверьте follow/unfollow user и channel.<br />
4) Откройте канал: проверьте like/unlike, reply и thread.
</p>
`;
const content = document.createElement('div');
const refresh = () => {
loadFeedAndRender(content, navigate);
};
screen.append(
renderHeader({
title: 'Каналы',
rightActions: [
{ label: 'Подписаться на человека', onClick: () => openSimpleSubscribeModal('Подписка на человека') },
{ label: 'Подписаться на канал', onClick: () => openSimpleSubscribeModal('Подписка на канал') },
],
})
);
loadFeedAndRender(screen, navigate);
const actions = document.createElement('div');
actions.className = 'channels-action-grid';
const actionButtons = [
{
label: 'Подписаться на пользователя',
className: 'primary-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на пользователя',
submitLabel: 'Подписаться',
onSuccess: refresh,
}),
},
{
label: 'Отписаться от пользователя',
className: 'destructive-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Отписка от пользователя',
submitLabel: 'Отписаться',
unfollow: true,
onSuccess: refresh,
}),
},
{
label: 'Подписаться на канал',
className: 'primary-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Подписка на канал',
submitLabel: 'Подписаться',
onSuccess: refresh,
}),
},
{
label: 'Отписаться от канала',
className: 'destructive-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Отписка от канала',
submitLabel: 'Отписаться',
unfollow: true,
onSuccess: refresh,
}),
},
];
actionButtons.forEach((config) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = config.className;
btn.textContent = config.label;
btn.addEventListener('click', config.onClick);
actions.append(btn);
});
const limitations = document.createElement('div');
limitations.className = 'channels-info-strip';
limitations.textContent = 'Подписка на пользователя и подписка на конкретный канал работают независимо.';
screen.append(hero);
screen.append(actions);
screen.append(currentUser);
if (flashCard) screen.append(flashCard);
screen.append(help);
screen.append(limitations);
screen.append(content);
refresh();
return screen;
}
+14 -5
View File
@@ -6,6 +6,7 @@ import {
setAuthError,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
@@ -32,7 +33,11 @@ export function render({ navigate }) {
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
hint.textContent = 'Введите логин и пароль. На следующем шаге сохраните ключи на устройстве.';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
@@ -40,7 +45,7 @@ export function render({ navigate }) {
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(hint);
form.append(hint, status);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@@ -56,11 +61,13 @@ export function render({ navigate }) {
enterButton.type = 'button';
enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => {
status.style.display = 'none';
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.password = passwordInput.value;
if (!state.loginDraft.login || !state.loginDraft.password) {
window.alert('Введите логин и пароль');
status.textContent = 'Введите логин и пароль.';
status.style.display = '';
return;
}
@@ -81,8 +88,10 @@ export function render({ navigate }) {
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
const message = toUserMessage(error, 'Не удалось выполнить вход.');
setAuthError(message);
status.textContent = message;
status.style.display = '';
} finally {
setAuthBusy(false);
enterButton.disabled = false;
+15 -1
View File
@@ -39,6 +39,20 @@ export function render({ navigate }) {
const card = document.createElement('div');
card.className = 'card stack';
const nextStepCard = document.createElement('div');
nextStepCard.className = 'card stack';
nextStepCard.innerHTML = `
<strong>Вы вошли как @${login}</strong>
<p class="meta-muted">Следующий шаг для ручной проверки: откройте вкладку «Каналы» в нижнем меню.</p>
`;
const openChannelsButton = document.createElement('button');
openChannelsButton.className = 'primary-btn';
openChannelsButton.type = 'button';
openChannelsButton.textContent = 'Открыть каналы';
openChannelsButton.addEventListener('click', () => navigate('channels-list'));
nextStepCard.append(openChannelsButton);
const topRow = document.createElement('div');
topRow.className = 'row';
topRow.innerHTML = `
@@ -201,7 +215,7 @@ export function render({ navigate }) {
shineBtn.addEventListener('click', () => onToggleClick('shine'));
card.append(topRow, badgesRow, status, listWrap);
screen.append(card);
screen.append(nextStepCard, card);
refreshProfileSnapshot();
+13 -5
View File
@@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@@ -28,6 +29,10 @@ export function render({ navigate }) {
statusText.className = 'meta-muted';
statusText.textContent = 'Проверка логина: не выполнена';
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
checkButton.type = 'button';
@@ -37,6 +42,7 @@ export function render({ navigate }) {
const login = loginInput.value.trim();
if (!login) {
statusText.textContent = 'Введите логин';
formError.style.display = 'none';
return false;
}
@@ -47,9 +53,10 @@ export function render({ navigate }) {
const isFree = await authService.ensureLoginFree(login);
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
statusText.className = isFree ? 'is-available' : 'is-unavailable';
formError.style.display = 'none';
return isFree;
} catch (error) {
statusText.textContent = error.message;
statusText.textContent = toUserMessage(error, 'Не удалось проверить логин');
statusText.className = 'is-unavailable';
return false;
} finally {
@@ -66,7 +73,7 @@ export function render({ navigate }) {
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(checkButton, statusText);
form.append(checkButton, statusText, formError);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@@ -82,9 +89,9 @@ export function render({ navigate }) {
nextButton.type = 'button';
nextButton.textContent = 'Далее';
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const isFree = await runAvailabilityCheck();
if (!isFree) {
window.alert('Выберите свободный логин');
return;
}
@@ -92,7 +99,8 @@ export function render({ navigate }) {
state.registrationDraft.password = passwordInput.value;
if (!state.registrationDraft.password) {
window.alert('Введите пароль');
formError.textContent = 'Введите пароль.';
formError.style.display = '';
return;
}
+21 -7
View File
@@ -7,6 +7,7 @@ import {
setAuthInfo,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
@@ -31,6 +32,14 @@ export function render({ navigate }) {
question.className = 'auth-copy';
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
const nextStep = document.createElement('p');
nextStep.className = 'meta-muted';
nextStep.textContent = 'После сохранения откроется профиль. Для проверки откройте вкладку «Каналы».';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
@@ -46,17 +55,17 @@ export function render({ navigate }) {
const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row';
rootRow.append(rootToggle, document.createTextNode('root key'));
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
const blockchainRow = document.createElement('label');
blockchainRow.className = 'checkbox-row';
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row';
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
card.append(title, question, rootRow, blockchainRow, deviceRow);
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@@ -72,6 +81,7 @@ export function render({ navigate }) {
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.addEventListener('click', async () => {
status.style.display = 'none';
try {
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
@@ -117,11 +127,15 @@ export function render({ navigate }) {
state.registrationDraft.pendingSessionMaterial = null;
await refreshSessions();
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
navigate('profile-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message);
status.textContent = message;
status.style.display = '';
}
});
+49 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import {
authService,
refreshRegistrationBalance,
@@ -6,9 +6,25 @@ import {
setAuthInfo,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REGISTER_BALANCE_SOL = 0.01;
function parseBalanceSol(value) {
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
return Number.isFinite(parsed) ? parsed : 0;
}
function getCryptoRuntimeState() {
const hasCrypto = Boolean(globalThis.crypto);
const hasGetRandomValues = Boolean(globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function');
const hasSubtle = Boolean(globalThis.crypto && (globalThis.crypto.subtle || globalThis.crypto.webkitSubtle));
const secureContext = window.isSecureContext === true;
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -16,6 +32,10 @@ export function render({ navigate }) {
const card = document.createElement('div');
card.className = 'card stack';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const walletValue = document.createElement('input');
walletValue.className = 'input';
walletValue.type = 'text';
@@ -39,7 +59,9 @@ export function render({ navigate }) {
copyButton.textContent = 'Скопировать номер';
}, 1500);
} catch {
window.alert('Не удалось скопировать номер кошелька.');
status.className = 'status-line is-unavailable';
status.textContent = 'Не удалось скопировать номер кошелька.';
status.style.display = '';
}
});
@@ -73,6 +95,24 @@ export function render({ navigate }) {
submitButton.type = 'button';
submitButton.textContent = 'Зарегистрироваться';
submitButton.addEventListener('click', async () => {
status.style.display = 'none';
const balanceSol = parseBalanceSol(state.registrationPayment.balanceSOL);
if (balanceSol < MIN_REGISTER_BALANCE_SOL) {
status.className = 'status-line is-unavailable';
status.textContent = `Недостаточный баланс для регистрации: ${state.registrationPayment.balanceSOL} SOL. Нужно минимум ${MIN_REGISTER_BALANCE_SOL.toFixed(2)} SOL.`;
status.style.display = '';
return;
}
const cryptoState = getCryptoRuntimeState();
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
status.className = 'status-line is-unavailable';
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
status.style.display = '';
return;
}
try {
submitButton.disabled = true;
submitButton.textContent = 'Регистрация...';
@@ -85,12 +125,14 @@ export function render({ navigate }) {
state.registrationDraft.pendingKeyBundle = result.keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
window.alert('Отлично, вы зарегистрировались');
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
navigate('registration-keys-view');
} catch (error) {
setAuthError(error.message);
window.alert(error.message);
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
status.style.display = '';
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Зарегистрироваться';
@@ -106,7 +148,7 @@ export function render({ navigate }) {
`;
card.children[1].append(walletRow);
card.children[2].append(balanceRow);
card.append(topupButton, submitButton);
card.append(topupButton, submitButton, status);
screen.append(
renderHeader({
+14 -1
View File
@@ -1,4 +1,4 @@
import { clearStartHint, state } from '../state.js';
import { clearStartHint, state } from '../state.js';
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
@@ -40,6 +40,19 @@ export function render({ navigate }) {
screen.append(logo, title);
const help = document.createElement('div');
help.className = 'card auth-status-card';
help.innerHTML = `
<strong>Локальный тест SHiNE</strong>
<p class="meta-muted" style="margin-top:6px;">
1) Локально: <code>?localWsPort=7071</code>; через tunnel: <code>?wsUrl=wss://.../ws</code>.<br />
2) Зарегистрируйте пользователя A, затем пользователя B.<br />
3) Войдите под A, создайте 2 канала и сообщения.<br />
4) Войдите под B и проверьте каналы, лайк/анлайк и подписки/отписки.
</p>
`;
screen.append(help);
if (state.startHint) {
const notice = document.createElement('div');
notice.className = 'card auth-status-card';
+37 -2
View File
@@ -19,16 +19,51 @@ export function getRoute() {
return { pageId: '', params: {} };
}
const [pageId, dynamicId] = raw.split('/');
const segments = raw.split('/').filter(Boolean);
const pageId = segments[0] || '';
const dynamicId = segments[1] || '';
const decodePart = (value) => {
try {
return decodeURIComponent(value || '');
} catch {
return value || '';
}
};
if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId || '' } };
}
if (pageId === 'channel-view') {
if (segments.length >= 4) {
return {
pageId,
params: {
ownerBlockchainName: decodePart(segments[1]),
channelRootBlockNumber: segments[2] || '',
channelRootBlockHash: segments[3] || '',
channelId: '',
},
};
}
return { pageId, params: { channelId: dynamicId || '' } };
}
if (pageId === 'channel-thread-view') {
return {
pageId,
params: {
messageBlockchainName: decodePart(segments[1]),
messageBlockNumber: segments[2] || '',
messageBlockHash: segments[3] || '',
channelOwnerBlockchainName: decodePart(segments[4]),
channelRootBlockNumber: segments[5] || '',
channelRootBlockHash: segments[6] || '',
},
};
}
if (pageId === 'device-session-view') {
return { pageId, params: { sessionId: dynamicId || '' } };
}
@@ -57,6 +92,6 @@ export function resolveToolbarActive(pageId) {
return 'profile-view';
}
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view') return 'channels-list';
return 'profile-view';
}
+666 -9
View File
@@ -12,6 +12,12 @@ import {
signBase64,
utf8Bytes,
} from './crypto-utils.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
toCanonicalChannelSlug,
validateChannelDisplayName,
} from './channel-name-rules.js';
import {
loadEncryptedUserSecrets,
loadSessionMaterial,
@@ -20,20 +26,50 @@ import {
} from './key-vault.js';
const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64);
const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
function normalizeServerUrl(url) {
const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('ws://') || value.startsWith('wss://')) {
try {
const parsed = new URL(value);
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return value;
}
}
if (value.startsWith('https://') || value.startsWith('http://')) {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
try {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
}
}
return value;
}
function opError(op, response) {
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
const code = response?.payload?.code || response?.code || 'UNKNOWN';
const payload = response?.payload || {};
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
@@ -51,11 +87,21 @@ function hexToBytes(hex) {
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i += 1) {
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(byte)) throw new Error('Некорректный hex');
out[i] = byte;
}
return out;
}
function normalizeHex32(value, fallback = ZERO64) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return fallback;
if (/^0+$/.test(raw)) return ZERO64;
if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
return raw;
}
function concatBytes(...chunks) {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
@@ -81,6 +127,12 @@ function int16Bytes(value) {
return bytes;
}
function int8Byte(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
return new Uint8Array([n & 0xff]);
}
function int64Bytes(value) {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
@@ -107,10 +159,179 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
);
}
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
const cleanBch = String(toBlockchainName || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for like');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for like');
}
const bchBytes = utf8Bytes(cleanBch);
if (bchBytes.length < 1 || bchBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes');
}
return concatBytes(
int8Byte(bchBytes.length),
bchBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex))
);
}
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
const cleanBch = String(toBlockchainName || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for reply');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for reply');
}
const message = String(text || '').trim();
if (!message) throw new Error('Reply text is required');
const bchBytes = utf8Bytes(cleanBch);
if (bchBytes.length < 1 || bchBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes');
}
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Reply text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(bchBytes.length),
bchBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
}
function makeConnectionBodyBytes({
lineCode = 0,
prevLineNumber = -1,
prevLineHashHex = ZERO64,
thisLineNumber = -1,
toBlockchainName,
toBlockNumber,
toBlockHashHex,
}) {
const cleanBch = String(toBlockchainName || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for connection');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for connection');
}
const bchBytes = utf8Bytes(cleanBch);
if (bchBytes.length < 1 || bchBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(bchBytes.length),
bchBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex))
);
}
function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, channelName }) {
const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code));
const cleanName = check.normalized;
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
throw new Error('Channel name must be 1..255 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(nameBytes.length),
nameBytes
);
}
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Message text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int16Bytes(textBytes.length),
textBytes
);
}
function normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || '').trim();
const cleanBlockNumber = Number(target?.blockNumber);
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();
if (!cleanBch) {
throw new Error(`Missing message target blockchain for ${actionName}`);
}
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
throw new Error(`Invalid message target block number for ${actionName}`);
}
if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
throw new Error(`Invalid message target hash for ${actionName}`);
}
return {
blockchainName: cleanBch,
blockNumber: cleanBlockNumber,
blockHash: cleanBlockHash,
};
}
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
const body = bodyBytes || new Uint8Array(0);
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
return concatBytes(
int16Bytes(0),
prevHashBytes,
int32Bytes(blockSize),
int32Bytes(blockNumber),
int64Bytes(Math.floor(Date.now() / 1000)),
int16Bytes(msgType),
int16Bytes(msgSubType),
int16Bytes(msgVersion),
body
);
}
export class AuthService {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map();
this.writeLocks = new Map();
}
async reconnect(serverUrl) {
@@ -119,6 +340,20 @@ export class AuthService {
this.ws.close();
this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map();
this.writeLocks.clear();
}
runWriteLocked(lockKey, runAction) {
const key = String(lockKey || '').trim() || 'write';
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
const task = (async () => runAction())().finally(() => {
this.writeLocks.delete(key);
});
this.writeLocks.set(key, task);
return task;
}
async getUser(login) {
@@ -293,18 +528,440 @@ export class AuthService {
return response.payload || {};
}
async getChannelMessages(channel, limit = 200, sort = 'asc') {
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
const payload = { channel, limit, sort };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload);
if (response.status !== 200) throw opError('GetChannelMessages', response);
return response.payload || {};
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const payload = { message, depthUp, depthDown, limitChildrenPerNode };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload);
if (response.status !== 200) throw opError('GetMessageThread', response);
return response.payload || {};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
const user = await this.getUser(cleanLogin);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const freshNum = Number(user?.serverLastGlobalNumber);
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash,
};
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
if (!blockchainPrivatePkcs8) {
throw new Error('Missing saved blockchain private key on device');
}
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
const preimage = buildBlockPreimage({
prevBlockHashHex: prevBlockHash,
blockNumber,
msgType,
msgSubType,
msgVersion,
bodyBytes,
});
const hash32 = await sha256Bytes(preimage);
const signatureBytes = await signBytes(privateKey, hash32);
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
return this.ws.request('AddBlock', {
blockchainName,
blockNumber,
prevBlockHash,
blockBytesB64: bytesToBase64(fullBlock),
});
};
let cursor = freshCursor;
let response = await tryAdd(cursor);
if (response.status !== 200) {
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
response = await tryAdd(cursor);
}
}
if (response.status !== 200) throw opError('AddBlock', response);
const payload = response.payload || {};
const acceptedNum = Number(payload?.serverLastGlobalNumber);
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
this.headerHashCache.set(blockchainName, acceptedHash);
}
return payload;
}
async ensureChainInitializedForLineOps(login, storagePwd) {
const current = await this.getUser(login);
const lastNum = Number(current?.serverLastGlobalNumber);
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
// Bootstrap an empty chain with a minimal USER_PARAM block so line-based
// channel operations have a valid anchor at block #0.
await this.addBlockUserParam({
login,
storagePwd,
param: 'shine',
value: 'yes',
});
return this.getUser(login);
}
async addBlockLike({ login, message, storagePwd }) {
const cleanLogin = String(login || '').trim();
const target = normalizeMessageRefTarget(message, 'like');
const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toBlockchainName: target.blockchainName,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_REACTION,
msgSubType: MSG_SUBTYPE_REACTION_LIKE,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockUnlike({ login, message, storagePwd }) {
const cleanLogin = String(login || '').trim();
const target = normalizeMessageRefTarget(message, 'unlike');
const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toBlockchainName: target.blockchainName,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_REACTION,
msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockReply({ login, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'reply');
const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextReplyBodyBytes({
toBlockchainName: target.blockchainName,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_REPLY,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
if (!cleanTargetLogin) throw new Error('Target login is required');
const cleanLogin = String(login || '').trim();
const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
const targetUser = await this.getUser(cleanTargetLogin);
if (!targetUser?.exists) throw new Error('Target user not found');
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);
return this.addBlockFollowChannel({
login: cleanLogin,
storagePwd,
targetBlockchainName: targetUser.blockchainName,
targetBlockNumber: 0,
targetBlockHashHex: targetHeaderHash,
unfollow,
});
});
}
async addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName,
targetBlockNumber,
targetBlockHashHex,
unfollow = false,
}) {
const cleanLogin = String(login || '').trim();
const cleanTargetBch = String(targetBlockchainName || '').trim();
const cleanTargetBlockNumber = Number(targetBlockNumber);
if (!cleanTargetBch) throw new Error('Target blockchain is required');
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
throw new Error('Invalid target block number');
}
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
return this.runWriteLocked(key, async () => {
let targetHashHex = seedHash;
if (targetHashHex === ZERO64) {
targetHashHex = cleanTargetBlockNumber === 0
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
}
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO64,
thisLineNumber: -1,
toBlockchainName: cleanTargetBch,
toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_CONNECTION,
msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
msgVersion: 1,
bodyBytes,
});
});
}
async getBlockHashByNumber(blockchainName, blockNumber) {
const cleanBlockNumber = Number(blockNumber);
try {
const payload = await this.getMessageThread(
{
blockchainName: String(blockchainName || '').trim(),
blockNumber: cleanBlockNumber,
blockHash: ZERO64,
},
0,
0,
1
);
const hash = payload?.focus?.messageRef?.blockHash;
return normalizeHex32(hash, ZERO64);
} catch (error) {
if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
return ZERO64;
}
throw error;
}
}
async resolveHeaderHashForBlockchain(blockchainName) {
const cleanBch = String(blockchainName || '').trim();
if (!cleanBch) throw new Error('Missing blockchainName');
if (this.headerHashCache.has(cleanBch)) {
const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
if (cached !== ZERO64) return cached;
this.headerHashCache.delete(cleanBch);
}
const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
if (headerHash !== ZERO64) {
this.headerHashCache.set(cleanBch, headerHash);
} else {
this.headerHashCache.delete(cleanBch);
}
return headerHash;
}
async listOwnChannelsForBlockchain(login, blockchainName) {
const feed = await this.listSubscriptionsFeed(login, 500);
const own = feed?.ownedChannels || [];
return own
.filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
.map((item) => ({
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
channelName: String(item?.channel?.channelName || ''),
}))
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
}
async addBlockCreateChannel({ login, channelName, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code));
const cleanChannelName = normalizeChannelDisplayName(check.normalized);
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const key = `create-channel:${cleanLogin}:${channelSlug || cleanChannelName.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const createdChannels = ownChannels
.filter((item) => item.rootBlockNumber > 0)
.sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);
let prevLineNumber = 0;
let prevLineHashHex = (
Number.isFinite(userLastGlobalNumber) &&
userLastGlobalNumber === 0 &&
userLastGlobalHash !== ZERO64
)
? userLastGlobalHash
: await this.resolveHeaderHashForBlockchain(blockchainName);
let thisLineNumber = 1;
if (createdChannels.length > 0) {
const last = createdChannels[createdChannels.length - 1];
prevLineNumber = last.rootBlockNumber;
prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
thisLineNumber = createdChannels.length + 1;
}
const bodyBytes = makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: 1,
bodyBytes,
});
return {
...payload,
channel: {
ownerBlockchainName: blockchainName,
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
},
};
});
}
async addBlockTextPost({ login, channel, text, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
const ownerBlockchainName = owner;
const lineCode = root;
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector');
}
if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (lineCode === 0) {
rootHashHex = (
Number.isFinite(userLastGlobalNumber) &&
userLastGlobalNumber === 0 &&
userLastGlobalHash !== ZERO64
)
? userLastGlobalHash
: await this.resolveHeaderHashForBlockchain(blockchainName);
} else if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
}
const bodyBytes = makeTextPostBodyBytes({
lineCode,
prevLineNumber: lineCode,
prevLineHashHex: rootHashHex,
thisLineNumber: 0,
text: cleanText,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_POST,
msgVersion: 1,
bodyBytes,
});
return {
...payload,
channel: {
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
},
};
});
}
onEvent(op, handler) {
return this.ws.onEvent(op, handler);
@@ -0,0 +1,79 @@
const MIN_LEN = 3;
const MAX_LEN = 32;
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
export function normalizeChannelDisplayName(value) {
if (value == null) return '';
return String(value).trim().replace(/\s+/g, ' ');
}
export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return '';
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
let out = '';
let pendingSeparator = false;
for (const ch of lowered) {
if (ch === ' ' || ch === '_' || ch === '-') {
pendingSeparator = out.length > 0;
continue;
}
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
return '';
}
if (pendingSeparator && out.length > 0) out += '-';
out += ch;
pendingSeparator = false;
}
return out.replace(/-+$/g, '');
}
export function validateChannelDisplayName(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) {
return { ok: false, code: 'blank', normalized: '', slug: '' };
}
const length = Array.from(normalized).length;
if (length < MIN_LEN) {
return { ok: false, code: 'too_short', normalized, slug: '' };
}
if (length > MAX_LEN) {
return { ok: false, code: 'too_long', normalized, slug: '' };
}
if (!ALLOWED_CHARS_RE.test(normalized)) {
return { ok: false, code: 'bad_chars', normalized, slug: '' };
}
if (normalized === '0') {
return { ok: false, code: 'reserved', normalized, slug: '' };
}
const slug = toCanonicalChannelSlug(normalized);
if (!slug) {
return { ok: false, code: 'bad_chars', normalized, slug: '' };
}
return { ok: true, code: '', normalized, slug };
}
export function channelNameErrorText(code) {
switch (String(code || '').trim()) {
case 'blank':
return 'Введите название канала.';
case 'too_short':
return 'Название слишком короткое: минимум 3 символа.';
case 'too_long':
return 'Название слишком длинное: максимум 32 символа.';
case 'bad_chars':
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
case 'reserved':
return 'Название "0" зарезервировано.';
default:
return 'Некорректное название канала.';
}
}
+37 -16
View File
@@ -1,4 +1,21 @@
const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
function getCryptoApi() {
const api = globalThis.crypto;
if (!api || typeof api.getRandomValues !== 'function') {
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
}
return api;
}
function getSubtleApi() {
const api = getCryptoApi();
if (!api.subtle) {
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
}
return api.subtle;
}
function base64UrlToBase64(value) {
@@ -8,7 +25,7 @@ function base64UrlToBase64(value) {
}
export function randomBase64(byteLen = 32) {
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes);
}
@@ -35,7 +52,7 @@ export function utf8Bytes(value) {
}
export async function sha256Bytes(bytes) {
const digest = await crypto.subtle.digest('SHA-256', bytes);
const digest = await getSubtleApi().digest('SHA-256', bytes);
return new Uint8Array(digest);
}
@@ -65,8 +82,9 @@ function ed25519Pkcs8FromSeed(seed32) {
export async function deriveEd25519FromPassword(password, suffix) {
const seed = await derivePasswordSeed(password, suffix);
const pkcs8 = ed25519Pkcs8FromSeed(seed);
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
const subtle = getSubtleApi();
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return {
@@ -77,7 +95,8 @@ export async function deriveEd25519FromPassword(password, suffix) {
}
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
const baseKey = await crypto.subtle.importKey(
const subtle = getSubtleApi();
const baseKey = await subtle.importKey(
'raw',
utf8Bytes(storagePwd),
{ name: 'PBKDF2' },
@@ -85,7 +104,7 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
['deriveKey'],
);
return crypto.subtle.deriveKey(
return subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
@@ -103,11 +122,13 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
}
export async function encryptJsonWithStoragePwd(value, storagePwd) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const cryptoApi = getCryptoApi();
const subtle = getSubtleApi();
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plainBytes = utf8Bytes(JSON.stringify(value));
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
saltB64: bytesToBase64(salt),
@@ -121,35 +142,35 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
const iv = base64ToBytes(envelope.ivB64);
const cipher = base64ToBytes(envelope.cipherB64);
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv }, key, cipher);
const text = new TextDecoder().decode(plain);
return JSON.parse(text);
}
export async function generateEd25519Pair() {
return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
}
export async function exportEd25519PublicKeyB64(publicKey) {
const raw = await crypto.subtle.exportKey('raw', publicKey);
const raw = await getSubtleApi().exportKey('raw', publicKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function exportPkcs8B64(privateKey) {
const raw = await crypto.subtle.exportKey('pkcs8', privateKey);
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function importPkcs8Ed25519(pkcs8B64) {
return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function signBase64(privateKey, text) {
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}
export async function signBytes(privateKey, bytes) {
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, bytes);
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
return new Uint8Array(signature);
}
+112
View File
@@ -0,0 +1,112 @@
function extractCode(message = '') {
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
return match ? String(match[1]).toUpperCase() : '';
}
function normalizeText(error) {
return String(error?.message || '').trim().toLowerCase();
}
export function toUserMessage(error, fallback = 'Действие не выполнено. Попробуйте еще раз.') {
const raw = String(error?.message || '').trim();
const text = normalizeText(error);
const code = String(error?.code || extractCode(raw) || '').toUpperCase();
if (
text.includes('webcrypto') ||
text.includes('crypto.subtle') ||
text.includes("reading 'digest'") ||
text.includes('not supported on insecure origins')
) {
return 'Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.';
}
if (
text.includes('mixed content') ||
text.includes('insecure websocket connection') ||
(text.includes('https') && text.includes('ws://'))
) {
return 'Подключение заблокировано: страница открыта по HTTPS, а сервер указан как ws://. Используйте wss://.';
}
if (
text.includes('не удалось подключиться') ||
text.includes('failed to connect websocket') ||
text.includes('websocket закрыто') ||
text.includes('соединение websocket закрыто')
) {
return 'Сервер недоступен. Проверьте, что backend запущен, и повторите попытку.';
}
if (text.includes('таймаут') || text.includes('timeout waiting')) {
return 'Сервер отвечает слишком долго. Повторите попытку через несколько секунд.';
}
if (
code === 'USER_NOT_FOUND' ||
text.includes('user not found') ||
text.includes('пользователь не найден')
) {
return 'Пользователь не найден. Проверьте логин.';
}
if (
code === 'BAD_CHANNEL_NAME' ||
text.includes('channel name must match') ||
text.includes('channelname contains unsupported') ||
text.includes('channelname length must be 3..32') ||
text.includes('bad_channel_name')
) {
return 'Некорректное название канала. Разрешены кириллица, латиница, цифры, пробел, _ и - (3..32 символа).';
}
if (text.includes('channel name is required') || text.includes('введите имя канала')) {
return 'Введите имя канала.';
}
if (code === 'CHANNEL_NAME_ALREADY_EXISTS' || text.includes('channel_name_already_exists')) {
return 'Такое название уже занято. Попробуйте немного изменить его.';
}
if (
code === 'PREV_LINE_BLOCK_NOT_FOUND' ||
code === 'LINE_ERR_NO_PREV' ||
text.includes('prev_line_block_not_found') ||
text.includes('line_err_no_prev')
) {
return 'Базовый блок линии не найден. Обновите страницу и повторите действие.';
}
if (
code === 'BAD_PREV_LINE_HASH' ||
code === 'LINE_ERR_PREV_HASH_MISMATCH' ||
text.includes('bad_prev_line_hash') ||
text.includes('line_err_prev_hash_mismatch') ||
text.includes('prevlinehash')
) {
return 'Конфликт состояния канала. Обновите страницу и повторите действие.';
}
if (code === 'LINE_ERR_PARTIAL_FIELDS' || text.includes('line_err_partial_fields')) {
return 'Некорректные данные канала. Обновите страницу и повторите действие.';
}
if (code === 'NOT_AUTHENTICATED' || text.includes('session is not ready for signing')) {
return 'Сессия недействительна. Выполните вход заново.';
}
if (
code === 'SESSION_NOT_FOUND' ||
code === 'SESSION_KEY_NOT_ACTUAL' ||
code === 'SESSION_OF_ANOTHER_USER'
) {
return 'Сессия устарела. Войдите заново и повторите действие.';
}
if (code === 'UNSUPPORTED_KEY_ALGORITHM' || text.includes('unsupported key algorithm')) {
return 'Ключ устройства не поддерживается сервером. Очистите локальные ключи и войдите заново.';
}
if (!raw) return fallback;
return raw;
}
+52 -4
View File
@@ -5,12 +5,52 @@ const DEFAULT_TIMEOUT_MS = 12000;
function buildWsUrl(raw) {
const value = (raw || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
if (value.startsWith('/')) {
const secure = window.location.protocol === 'https:';
const scheme = secure ? 'wss' : 'ws';
return `${scheme}://${window.location.host}${value}`;
}
if (value.startsWith('ws://') || value.startsWith('wss://')) {
try {
const parsed = new URL(value);
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return value;
}
}
if (value.startsWith('http://') || value.startsWith('https://')) {
try {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return value.startsWith('https://')
? `wss://${value.slice('https://'.length)}`
: `ws://${value.slice('http://'.length)}`;
}
}
return value;
}
function isLoopbackHost(hostname = '') {
const host = String(hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
}
function isMixedContentWs(url) {
try {
const pageIsHttps = window.location.protocol === 'https:';
if (!pageIsHttps) return false;
const parsed = new URL(url);
if (parsed.protocol !== 'ws:') return false;
return !isLoopbackHost(parsed.hostname);
} catch {
return false;
}
}
function createRequestId(op) {
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
@@ -27,6 +67,15 @@ export class WsJsonClient {
async open() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.openPromise) return this.openPromise;
if (isMixedContentWs(this.url)) {
const error = new Error('Страница открыта по HTTPS, а сервер указан как ws://. Используйте wss:// адрес для Shine сервера.');
captureClientError({
kind: 'ws_mixed_content_blocked',
message: error.message,
context: { url: this.url, pageProtocol: window.location.protocol },
});
throw error;
}
this.openPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(this.url);
@@ -54,7 +103,6 @@ export class WsJsonClient {
});
}).finally(() => {
this.openPromise = null;
this.eventListeners = new Map();
});
return this.openPromise;
+103 -3
View File
@@ -4,6 +4,7 @@ import { clearClientAuthData } from './services/key-vault.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED',
'SESSION_NOT_FOUND',
@@ -13,18 +14,63 @@ const INVALID_SESSION_CODES = new Set([
function readLocalWsOverrideUrl() {
try {
const value = new URLSearchParams(window.location.search).get('localWsPort');
const params = new URLSearchParams(window.location.search);
const explicitWsUrl = String(params.get('wsUrl') || '').trim();
if (explicitWsUrl) {
if (explicitWsUrl.startsWith('ws://') || explicitWsUrl.startsWith('wss://')) {
try {
const parsed = new URL(explicitWsUrl);
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return explicitWsUrl;
}
}
if (explicitWsUrl.startsWith('http://') || explicitWsUrl.startsWith('https://')) {
try {
const parsed = new URL(explicitWsUrl);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return `${explicitWsUrl.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
}
}
return '';
}
const value = params.get('localWsPort');
const asNum = Number(value);
if (!Number.isFinite(asNum)) return '';
const port = Math.trunc(asNum);
if (port <= 0 || port > 65535) return '';
return `ws://localhost:${port}/ws`;
const isHttpsPage = window.location.protocol === 'https:';
const forceInsecureLocal = params.get('allowInsecureLocalWs') === '1';
const scheme = (isHttpsPage && !forceInsecureLocal) ? 'wss' : 'ws';
return `${scheme}://localhost:${port}/ws`;
} catch {
return '';
}
}
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl();
function inferTunnelWsUrl() {
try {
const host = String(window.location.host || '').toLowerCase();
const isTunnelHost = (
host.endsWith('.ngrok-free.dev') ||
host.endsWith('.ngrok.io') ||
host.endsWith('.trycloudflare.com')
);
if (!isTunnelHost) return '';
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${window.location.host}/ws`;
} catch {
return '';
}
}
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl();
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
function loadStoredSession() {
@@ -37,6 +83,26 @@ function loadStoredSession() {
}
}
function loadStoredReactions() {
try {
const raw = localStorage.getItem(REACTIONS_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed;
} catch {
return {};
}
}
function persistStoredReactions(reactions) {
try {
localStorage.setItem(REACTIONS_STORAGE_KEY, JSON.stringify(reactions || {}));
} catch {
// ignore storage errors
}
}
function persistSession(session) {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
@@ -55,6 +121,7 @@ function clearStoredSession() {
function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null;
const storedReactions = loadStoredReactions();
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
return {
@@ -120,6 +187,7 @@ function createInitialState({ withStoredSession = true } = {}) {
channelsFeed: null,
channelsIndex: {},
localChannelPosts: {},
messageReactions: storedReactions,
};
}
@@ -247,6 +315,10 @@ function resetStateForSignedOut() {
state.deviceConnect = next.deviceConnect;
state.authUi = next.authUi;
state.sessions = next.sessions;
state.channelsFeed = next.channelsFeed;
state.channelsIndex = next.channelsIndex;
state.localChannelPosts = next.localChannelPosts;
state.messageReactions = next.messageReactions;
}
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
@@ -295,3 +367,31 @@ export function addLocalChannelPost(channelId, post) {
body: text,
});
}
function makeMessageReactionKey(messageRef, login = state.session.login) {
const bch = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = String(messageRef?.blockHash || '').trim().toLowerCase();
const cleanLogin = String(login || '').trim().toLowerCase();
if (!cleanLogin || !bch || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${cleanLogin}|${bch}|${blockNumber}|${blockHash}`;
}
export function getMessageReactionState(messageRef) {
const key = makeMessageReactionKey(messageRef);
if (!key) return '';
return state.messageReactions[key] || '';
}
export function setMessageReactionState(messageRef, nextState) {
const key = makeMessageReactionKey(messageRef);
if (!key) return;
const normalized = String(nextState || '').trim().toLowerCase();
if (normalized === 'liked' || normalized === 'unliked') {
state.messageReactions[key] = normalized;
persistStoredReactions(state.messageReactions);
return;
}
delete state.messageReactions[key];
persistStoredReactions(state.messageReactions);
}