feat: finalize channels fixes and runtime stability
This commit is contained in:
parent
0c7d8fac02
commit
a9c69e5947
@ -4,6 +4,12 @@ plugins {
|
|||||||
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
group = 'shine'
|
group = 'shine'
|
||||||
version = '1.1_codex'
|
version = '1.1_codex'
|
||||||
|
|
||||||
|
|||||||
197
scripts/shine-ui-gateway.mjs
Normal file
197
scripts/shine-ui-gateway.mjs
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
const uiRoot = path.resolve(projectRoot, 'shine-UI');
|
||||||
|
|
||||||
|
const listenPort = Number(process.env.SHINE_UI_PORT || 8088);
|
||||||
|
const backendHost = process.env.SHINE_BACKEND_HOST || '127.0.0.1';
|
||||||
|
const backendPort = Number(process.env.SHINE_BACKEND_PORT || 7071);
|
||||||
|
const backendWsPath = process.env.SHINE_BACKEND_WS_PATH || '/ws';
|
||||||
|
|
||||||
|
const textContentTypes = new Set([
|
||||||
|
'.html',
|
||||||
|
'.css',
|
||||||
|
'.js',
|
||||||
|
'.mjs',
|
||||||
|
'.json',
|
||||||
|
'.txt',
|
||||||
|
'.svg',
|
||||||
|
'.xml',
|
||||||
|
'.webmanifest',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mimeByExt = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml; charset=utf-8',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizePathname(pathname) {
|
||||||
|
const withoutQuery = String(pathname || '').split('?')[0];
|
||||||
|
const decoded = decodeURIComponent(withoutQuery);
|
||||||
|
const normalized = path.posix.normalize(decoded);
|
||||||
|
if (normalized.includes('..')) return null;
|
||||||
|
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalFilePath(pathname) {
|
||||||
|
const safePath = sanitizePathname(pathname);
|
||||||
|
if (!safePath) return null;
|
||||||
|
const target = safePath === '/' ? '/index.html' : safePath;
|
||||||
|
return path.resolve(uiRoot, `.${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideUiRoot(filePath) {
|
||||||
|
const rel = path.relative(uiRoot, filePath);
|
||||||
|
return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryReadFile(filePath) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (!stat.isFile()) return null;
|
||||||
|
const data = await fs.readFile(filePath);
|
||||||
|
return { data, stat };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCorsAndCacheHeaders(res) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHttp(req, res) {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUrl = req.url || '/';
|
||||||
|
const pathname = rawUrl.split('?')[0] || '/';
|
||||||
|
if (pathname.startsWith('/ws')) {
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(426, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: 'upgrade_required', message: 'Use WebSocket upgrade for /ws' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directPath = toLocalFilePath(pathname);
|
||||||
|
if (!directPath || !isInsideUiRoot(directPath)) {
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Bad path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = await tryReadFile(directPath);
|
||||||
|
if (!file) {
|
||||||
|
const fallback = path.resolve(uiRoot, 'index.html');
|
||||||
|
file = await tryReadFile(fallback);
|
||||||
|
if (!file) {
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('index.html not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(directPath).toLowerCase();
|
||||||
|
const contentType = mimeByExt[ext] || 'application/octet-stream';
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Length': file.stat.size,
|
||||||
|
});
|
||||||
|
res.end(file.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpstreamUpgradeRequest(req) {
|
||||||
|
const sourceUrl = new URL(req.url || '/ws', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const targetPath = `${backendWsPath}${sourceUrl.search || ''}`;
|
||||||
|
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
headers.host = `${backendHost}:${backendPort}`;
|
||||||
|
headers.connection = 'Upgrade';
|
||||||
|
headers.upgrade = 'websocket';
|
||||||
|
|
||||||
|
let raw = `GET ${targetPath} HTTP/1.1\r\n`;
|
||||||
|
Object.entries(headers).forEach(([name, value]) => {
|
||||||
|
if (value == null) return;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((single) => {
|
||||||
|
raw += `${name}: ${single}\r\n`;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raw += `${name}: ${value}\r\n`;
|
||||||
|
});
|
||||||
|
raw += '\r\n';
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpgrade(req, socket, head) {
|
||||||
|
const pathname = String(req.url || '').split('?')[0] || '';
|
||||||
|
if (!pathname.startsWith('/ws')) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = net.connect(backendPort, backendHost, () => {
|
||||||
|
const upstreamRequest = buildUpstreamUpgradeRequest(req);
|
||||||
|
upstream.write(upstreamRequest);
|
||||||
|
if (head?.length) {
|
||||||
|
upstream.write(head);
|
||||||
|
}
|
||||||
|
socket.pipe(upstream).pipe(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBoth = () => {
|
||||||
|
if (!socket.destroyed) socket.destroy();
|
||||||
|
if (!upstream.destroyed) upstream.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
upstream.on('error', closeBoth);
|
||||||
|
socket.on('error', closeBoth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
handleHttp(req, res).catch(() => {
|
||||||
|
writeCorsAndCacheHeaders(res);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Internal gateway error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', handleUpgrade);
|
||||||
|
|
||||||
|
server.listen(listenPort, () => {
|
||||||
|
console.log(`[shine-ui-gateway] uiRoot=${uiRoot}`);
|
||||||
|
console.log(`[shine-ui-gateway] listening=http://localhost:${listenPort}`);
|
||||||
|
console.log(`[shine-ui-gateway] ws proxy=ws://${backendHost}:${backendPort}${backendWsPath}`);
|
||||||
|
});
|
||||||
@ -6,7 +6,7 @@
|
|||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<title>Shine UI Demo</title>
|
<title>Shine UI Demo</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260407120000';
|
window.__SHINE_BUILD_HASH__ = '20260413151200';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import * as contactSearchView from './pages/contact-search-view.js';
|
|||||||
import * as chatView from './pages/chat-view.js';
|
import * as chatView from './pages/chat-view.js';
|
||||||
import * as channelsList from './pages/channels-list.js';
|
import * as channelsList from './pages/channels-list.js';
|
||||||
import * as channelView from './pages/channel-view.js';
|
import * as channelView from './pages/channel-view.js';
|
||||||
|
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as networkView from './pages/network-view.js';
|
import * as networkView from './pages/network-view.js';
|
||||||
import * as notificationsView from './pages/notifications-view.js';
|
import * as notificationsView from './pages/notifications-view.js';
|
||||||
@ -72,6 +73,7 @@ const routes = {
|
|||||||
'chat-view': chatView,
|
'chat-view': chatView,
|
||||||
'channels-list': channelsList,
|
'channels-list': channelsList,
|
||||||
'channel-view': channelView,
|
'channel-view': channelView,
|
||||||
|
'channel-thread-view': channelThreadView,
|
||||||
'add-channel-view': addChannelView,
|
'add-channel-view': addChannelView,
|
||||||
'network-view': networkView,
|
'network-view': networkView,
|
||||||
'notifications-view': notificationsView,
|
'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() {
|
function renderApp() {
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||||
@ -162,18 +203,26 @@ function renderApp() {
|
|||||||
currentCleanup = null;
|
currentCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
screenEl.innerHTML = '';
|
try {
|
||||||
const screen = page.render({ route, navigate });
|
screenEl.innerHTML = '';
|
||||||
screenEl.append(screen);
|
const screen = page.render({ route, navigate });
|
||||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
if (!(screen instanceof Node)) {
|
||||||
|
throw new Error('Page render returned invalid node');
|
||||||
|
}
|
||||||
|
|
||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
screenEl.append(screen);
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
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.innerHTML = '';
|
||||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
if (showAppChrome) {
|
||||||
|
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[renderApp] controlled fallback', error);
|
||||||
|
renderPageFailureFallback(pageId, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,38 +1,123 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack channels-screen channels-screen--add';
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Добавить канал',
|
title: 'Создать канал',
|
||||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label for="channel-name">Имя канала</label>
|
<strong class="channel-head-title">Создание канала</strong>
|
||||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
|
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
<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="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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
form.addEventListener('submit', (event) => {
|
const inputEl = form.querySelector('#channel-name');
|
||||||
event.preventDefault();
|
const errorEl = form.querySelector('#channel-create-error');
|
||||||
navigate('channels-list');
|
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');
|
navigate('channels-list');
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.append(form);
|
screen.append(form);
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.focus();
|
||||||
|
updateValidation();
|
||||||
|
}
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
461
shine-UI/js/pages/channel-thread-view.js
Normal file
461
shine-UI/js/pages/channel-thread-view.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,11 +1,169 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { channelPosts, channels } from '../mock-data.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: 'Канал' };
|
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) {
|
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 {
|
return {
|
||||||
channel,
|
channel,
|
||||||
posts: [
|
posts: [
|
||||||
@ -13,33 +171,62 @@ function findMockChannel(channelId) {
|
|||||||
...getLocalChannelPosts(channelId),
|
...getLocalChannelPosts(channelId),
|
||||||
],
|
],
|
||||||
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
||||||
|
selector: null,
|
||||||
|
localKey: channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapApiMessageToPost(message) {
|
function openReplyModal({ onSubmit }) {
|
||||||
return {
|
const root = document.getElementById('modal-root');
|
||||||
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
|
root.innerHTML = `
|
||||||
body: message.text || '(пусто)',
|
<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) {
|
function openAddMessageModal({ channelName, onSubmit }) {
|
||||||
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 }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="channel-message-modal">
|
<div class="modal" id="channel-message-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 style="font-size:18px;">Новое сообщение в канал</h3>
|
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||||
<p class="meta-muted"># ${channelName}</p>
|
<p class="meta-muted"># ${channelName}</p>
|
||||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Введите текст сообщения"></textarea>
|
<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 class="meta-muted inline-error" id="channel-message-error"></div>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||||
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
@ -54,110 +241,314 @@ function openAddMessageModal({ channelId, channelName, onSubmit }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
||||||
root.querySelector('#channel-message-submit').addEventListener('click', () => {
|
root.querySelector('#channel-message-submit').addEventListener('click', async () => {
|
||||||
const body = textEl.value.trim();
|
const body = String(textEl?.value || '').trim();
|
||||||
if (!body) {
|
if (!body) {
|
||||||
errorEl.textContent = 'Введите текст сообщения.';
|
errorEl.textContent = 'Введите текст сообщения.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit({
|
try {
|
||||||
title: `${state.session.login || 'Вы'} • сейчас`,
|
await onSubmit({
|
||||||
body,
|
title: `${state.session.login || 'вы'} - сейчас`,
|
||||||
});
|
body,
|
||||||
close();
|
});
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (textEl) textEl.focus();
|
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');
|
const head = document.createElement('div');
|
||||||
head.className = 'card';
|
head.className = 'card channel-head-card';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
<strong># ${channelData.channel.name}</strong>
|
<strong class="channel-head-title">${channelData.channel.displayName || channelData.channel.name}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
|
<p class="channel-head-meta">${channelData.channel.description}</p>
|
||||||
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
|
<p class="channel-head-meta">Владелец: ${channelData.channel.ownerName}</p>
|
||||||
|
<p class="channel-note">Состояние лайка обновляется после подтверждённого reread с сервера.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
|
actionButton.className = channelData.isOwnChannel
|
||||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
|
? 'primary-btn channel-main-action'
|
||||||
|
: 'destructive-btn channel-main-action';
|
||||||
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
||||||
|
let followLimit = null;
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack';
|
feed.className = 'stack channel-feed';
|
||||||
|
|
||||||
channelData.posts.forEach((post) => {
|
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) {
|
if (channelData.isOwnChannel) {
|
||||||
actionButton.addEventListener('click', () => {
|
actionButton.addEventListener('click', () => {
|
||||||
openAddMessageModal({
|
openAddMessageModal({
|
||||||
channelId,
|
|
||||||
channelName: channelData.channel.name,
|
channelName: channelData.channel.name,
|
||||||
onSubmit: (post) => {
|
onSubmit: async (post) => handlers.onAddPost(post),
|
||||||
addLocalChannelPost(channelId, post);
|
|
||||||
channelData.posts.push(post);
|
|
||||||
feed.append(renderPostCard(post));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
followLimit = document.createElement('p');
|
||||||
|
followLimit.className = 'channel-note';
|
||||||
|
followLimit.textContent = 'Отписка удаляет только эту подписку на канал.';
|
||||||
|
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
const backButton = document.createElement('button');
|
||||||
backButton.className = 'secondary-btn';
|
backButton.className = 'secondary-btn channel-back-btn';
|
||||||
backButton.textContent = 'Назад к списку';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
|
if (followLimit) {
|
||||||
|
screen.append(head, followLimit, actionButton, feed, backButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
screen.append(head, actionButton, feed, backButton);
|
screen.append(head, actionButton, feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(channelId) {
|
async function loadFromApi(route, channelId) {
|
||||||
const summary = state.channelsIndex[channelId];
|
const selector = buildSelectorFromRoute(route, channelId);
|
||||||
if (!summary) return null;
|
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
|
||||||
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
|
}
|
||||||
|
|
||||||
const selector = {
|
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||||
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
const localKey = localPostsKey(selector, channelId);
|
||||||
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 posts = [
|
const posts = [
|
||||||
...(payload.messages || []).map(mapApiMessageToPost),
|
...(payload.messages || []).map((message) => mapApiMessageToPost(message, selector)),
|
||||||
...getLocalChannelPosts(channelId),
|
...getLocalChannelPosts(localKey),
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: {
|
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}`,
|
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
|
||||||
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
|
ownerName: payload.channel?.ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
|
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 }) {
|
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');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack channels-screen channels-screen--channel';
|
||||||
|
|
||||||
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
|
const fallbackName = channels.find((c) => c.id === channelId)?.name || 'Канал';
|
||||||
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
|
const titleFromIndex = state.channelsIndex[channelId]?.channel?.channelName;
|
||||||
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: headerTitle,
|
title: headerTitle,
|
||||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
screen.append(userIndicator, statusBox);
|
||||||
|
|
||||||
const loading = document.createElement('div');
|
const loading = document.createElement('div');
|
||||||
loading.className = 'card meta-muted';
|
loading.className = 'card meta-muted';
|
||||||
@ -166,18 +557,60 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
loading.remove();
|
loading.remove();
|
||||||
if (apiData) {
|
renderBody(screen, navigate, apiData, {
|
||||||
renderBody(screen, navigate, channelId, 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;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
||||||
// fallback to mock below
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.remove();
|
|
||||||
renderBody(screen, navigate, channelId, findMockChannel(channelId));
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -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 { channels as mockChannels } from '../mock-data.js';
|
||||||
import { authService, setChannelsFeed, state } from '../state.js';
|
import { authService, setChannelsFeed, state } from '../state.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||||
|
|
||||||
function openSimpleSubscribeModal(kindLabel) {
|
function isChannelsDemoMode() {
|
||||||
const root = document.getElementById('modal-root');
|
try {
|
||||||
root.innerHTML = `
|
const qs = new URLSearchParams(window.location.search);
|
||||||
<div class="modal" id="channels-subscribe-modal">
|
if (qs.get('channelsDemo') === '1') return true;
|
||||||
<div class="modal-card stack">
|
return localStorage.getItem('shine-channels-demo') === '1';
|
||||||
<h3 style="font-size:18px;">${kindLabel}</h3>
|
} catch {
|
||||||
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
|
return false;
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const close = () => {
|
function normalizeHash(hash) {
|
||||||
root.innerHTML = '';
|
const normalized = String(hash || '').trim().toLowerCase();
|
||||||
};
|
return normalized || '0';
|
||||||
|
}
|
||||||
|
|
||||||
root.querySelector('#sub-cancel').addEventListener('click', close);
|
function encodeRoutePart(value = '') {
|
||||||
root.querySelector('#sub-submit').addEventListener('click', close);
|
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 = '') {
|
function initialsFromName(name = '') {
|
||||||
@ -33,30 +38,202 @@ function initialsFromName(name = '') {
|
|||||||
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
|
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() {
|
function mapMockGroups() {
|
||||||
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
|
const mapRow = (channel) => ({
|
||||||
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
|
...channel,
|
||||||
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
|
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: {} };
|
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapApiChannelRow(summary, bucketKey, idx, index) {
|
function mapApiChannelRow(summary, bucketKey, idx, index) {
|
||||||
const rowId = `${bucketKey}-${idx}`;
|
const rowId = `${bucketKey}-${idx}`;
|
||||||
index[rowId] = summary;
|
index[rowId] = summary;
|
||||||
|
const ownerLogin = summary.channel?.ownerLogin || 'неизвестно';
|
||||||
|
const channelName = summary.channel?.channelName || '(без названия)';
|
||||||
|
const displayName = `${ownerLogin}/${channelName}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
source: 'api',
|
source: 'api',
|
||||||
ownerName: summary.channel?.ownerLogin || 'unknown',
|
route: buildChannelRouteFromSummary(summary, rowId),
|
||||||
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
|
ownerName: ownerLogin,
|
||||||
name: summary.channel?.channelName || '(без имени)',
|
initials: initialsFromName(channelName || ownerLogin || '?'),
|
||||||
description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
name: channelName,
|
||||||
|
displayName,
|
||||||
|
description: `bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
||||||
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
|
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,
|
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) {
|
function mapApiFeed(feed) {
|
||||||
const index = {};
|
const index = {};
|
||||||
|
|
||||||
@ -77,40 +254,48 @@ function mapApiFeed(feed) {
|
|||||||
|
|
||||||
function renderChannelRow(channel, navigate) {
|
function renderChannelRow(channel, navigate) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item';
|
row.className = 'channel-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${channel.initials}</div>
|
<div class="avatar">${channel.initials}</div>
|
||||||
<div>
|
<div class="channel-row-main">
|
||||||
<strong># ${channel.name}</strong>
|
<strong class="channel-row-title">${channel.displayName || channel.name}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
|
<p class="channel-row-description">${channel.description}</p>
|
||||||
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
|
<p class="channel-row-message">${channel.lastMessage}</p>
|
||||||
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</p>
|
<p class="channel-row-owner">Владелец: ${channel.ownerName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid; justify-items:end; gap:6px;">
|
<div class="channel-row-meta">
|
||||||
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
|
<span class="channel-row-kind">Канал</span>
|
||||||
<span class="meta-muted">${channel.time}</span>
|
<span class="channel-row-time">${channel.time}</span>
|
||||||
<span class="unread">${channel.messagesCount}</span>
|
<span class="unread channel-row-count">${channel.messagesCount}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
|
row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSection(title, items, navigate) {
|
function renderSection(title, items, navigate) {
|
||||||
const wrap = document.createElement('section');
|
const wrap = document.createElement('section');
|
||||||
wrap.className = 'stack';
|
wrap.className = 'stack channels-section';
|
||||||
|
|
||||||
const header = document.createElement('h3');
|
const header = document.createElement('h3');
|
||||||
header.className = 'section-title';
|
header.className = 'section-title';
|
||||||
header.textContent = title;
|
header.textContent = title;
|
||||||
|
|
||||||
wrap.append(header);
|
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)));
|
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupedList(screen, navigate, groups) {
|
function renderGroupedList(container, navigate, groups) {
|
||||||
const listWrap = document.createElement('div');
|
const listWrap = document.createElement('div');
|
||||||
listWrap.className = 'channels-scroll-wrap';
|
listWrap.className = 'channels-scroll-wrap';
|
||||||
|
|
||||||
@ -123,17 +308,17 @@ function renderGroupedList(screen, navigate, groups) {
|
|||||||
dividerOne.className = 'channels-divider';
|
dividerOne.className = 'channels-divider';
|
||||||
list.append(dividerOne);
|
list.append(dividerOne);
|
||||||
|
|
||||||
list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate));
|
list.append(renderSection('Каналы пользователей, на которых я подписан', groups.followedUserChannels, navigate));
|
||||||
|
|
||||||
const dividerTwo = document.createElement('hr');
|
const dividerTwo = document.createElement('hr');
|
||||||
dividerTwo.className = 'channels-divider';
|
dividerTwo.className = 'channels-divider';
|
||||||
list.append(dividerTwo);
|
list.append(dividerTwo);
|
||||||
|
|
||||||
list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate));
|
list.append(renderSection('Каналы, на которые я подписан', groups.subscribedChannels, navigate));
|
||||||
|
|
||||||
const addChannelButton = document.createElement('button');
|
const addChannelButton = document.createElement('button');
|
||||||
addChannelButton.className = 'primary-btn';
|
addChannelButton.className = 'primary-btn channels-bottom-action';
|
||||||
addChannelButton.textContent = 'Добавить канал';
|
addChannelButton.textContent = 'Создать канал';
|
||||||
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
|
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
|
||||||
list.append(addChannelButton);
|
list.append(addChannelButton);
|
||||||
@ -142,43 +327,195 @@ function renderGroupedList(screen, navigate, groups) {
|
|||||||
scrollHint.className = 'channels-scroll-hint';
|
scrollHint.className = 'channels-scroll-hint';
|
||||||
|
|
||||||
listWrap.append(list, scrollHint);
|
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');
|
const status = document.createElement('div');
|
||||||
status.className = 'card meta-muted';
|
status.className = 'card meta-muted';
|
||||||
status.textContent = 'Загрузка каналов с сервера...';
|
status.textContent = 'Загрузка каналов...';
|
||||||
screen.append(status);
|
container.append(status);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!state.session.login) throw new Error('not_authorized');
|
if (!state.session.login) throw new Error('not_authorized');
|
||||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||||
const groups = mapApiFeed(feed);
|
const groups = mapApiFeed(feed);
|
||||||
setChannelsFeed(feed, groups.index);
|
setChannelsFeed(feed, groups.index);
|
||||||
status.remove();
|
|
||||||
renderGroupedList(screen, navigate, groups);
|
container.innerHTML = '';
|
||||||
} catch {
|
renderGroupedList(container, navigate, groups);
|
||||||
|
} catch (error) {
|
||||||
setChannelsFeed(null, {});
|
setChannelsFeed(null, {});
|
||||||
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
|
container.innerHTML = '';
|
||||||
renderGroupedList(screen, navigate, mapMockGroups());
|
if (isChannelsDemoMode()) {
|
||||||
|
renderDemoFallback(container, navigate, error, () => loadFeedAndRender(container, navigate));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderErrorState(container, error, () => loadFeedAndRender(container, navigate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Каналы',
|
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;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
setAuthError,
|
setAuthError,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||||
|
|
||||||
@ -32,7 +33,11 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const hint = document.createElement('p');
|
const hint = document.createElement('p');
|
||||||
hint.className = 'meta-muted';
|
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 = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
@ -40,7 +45,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(hint);
|
form.append(hint, status);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -56,11 +61,13 @@ export function render({ navigate }) {
|
|||||||
enterButton.type = 'button';
|
enterButton.type = 'button';
|
||||||
enterButton.textContent = 'Войти';
|
enterButton.textContent = 'Войти';
|
||||||
enterButton.addEventListener('click', async () => {
|
enterButton.addEventListener('click', async () => {
|
||||||
|
status.style.display = 'none';
|
||||||
state.loginDraft.login = loginInput.value.trim();
|
state.loginDraft.login = loginInput.value.trim();
|
||||||
state.loginDraft.password = passwordInput.value;
|
state.loginDraft.password = passwordInput.value;
|
||||||
|
|
||||||
if (!state.loginDraft.login || !state.loginDraft.password) {
|
if (!state.loginDraft.login || !state.loginDraft.password) {
|
||||||
window.alert('Введите логин и пароль');
|
status.textContent = 'Введите логин и пароль.';
|
||||||
|
status.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,8 +88,10 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||||
navigate('registration-keys-view');
|
navigate('registration-keys-view');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(error.message);
|
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||||
window.alert(error.message);
|
setAuthError(message);
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.display = '';
|
||||||
} finally {
|
} finally {
|
||||||
setAuthBusy(false);
|
setAuthBusy(false);
|
||||||
enterButton.disabled = false;
|
enterButton.disabled = false;
|
||||||
|
|||||||
@ -39,6 +39,20 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
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');
|
const topRow = document.createElement('div');
|
||||||
topRow.className = 'row';
|
topRow.className = 'row';
|
||||||
topRow.innerHTML = `
|
topRow.innerHTML = `
|
||||||
@ -201,7 +215,7 @@ export function render({ navigate }) {
|
|||||||
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
||||||
|
|
||||||
card.append(topRow, badgesRow, status, listWrap);
|
card.append(topRow, badgesRow, status, listWrap);
|
||||||
screen.append(card);
|
screen.append(nextStepCard, card);
|
||||||
|
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, state } from '../state.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 };
|
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||||
|
|
||||||
@ -28,6 +29,10 @@ export function render({ navigate }) {
|
|||||||
statusText.className = 'meta-muted';
|
statusText.className = 'meta-muted';
|
||||||
statusText.textContent = 'Проверка логина: не выполнена';
|
statusText.textContent = 'Проверка логина: не выполнена';
|
||||||
|
|
||||||
|
const formError = document.createElement('p');
|
||||||
|
formError.className = 'status-line is-unavailable';
|
||||||
|
formError.style.display = 'none';
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
const checkButton = document.createElement('button');
|
||||||
checkButton.className = 'ghost-btn';
|
checkButton.className = 'ghost-btn';
|
||||||
checkButton.type = 'button';
|
checkButton.type = 'button';
|
||||||
@ -37,6 +42,7 @@ export function render({ navigate }) {
|
|||||||
const login = loginInput.value.trim();
|
const login = loginInput.value.trim();
|
||||||
if (!login) {
|
if (!login) {
|
||||||
statusText.textContent = 'Введите логин';
|
statusText.textContent = 'Введите логин';
|
||||||
|
formError.style.display = 'none';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,9 +53,10 @@ export function render({ navigate }) {
|
|||||||
const isFree = await authService.ensureLoginFree(login);
|
const isFree = await authService.ensureLoginFree(login);
|
||||||
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
|
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
|
||||||
statusText.className = isFree ? 'is-available' : 'is-unavailable';
|
statusText.className = isFree ? 'is-available' : 'is-unavailable';
|
||||||
|
formError.style.display = 'none';
|
||||||
return isFree;
|
return isFree;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusText.textContent = error.message;
|
statusText.textContent = toUserMessage(error, 'Не удалось проверить логин');
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'is-unavailable';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -66,7 +73,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(checkButton, statusText);
|
form.append(checkButton, statusText, formError);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -82,9 +89,9 @@ export function render({ navigate }) {
|
|||||||
nextButton.type = 'button';
|
nextButton.type = 'button';
|
||||||
nextButton.textContent = 'Далее';
|
nextButton.textContent = 'Далее';
|
||||||
nextButton.addEventListener('click', async () => {
|
nextButton.addEventListener('click', async () => {
|
||||||
|
formError.style.display = 'none';
|
||||||
const isFree = await runAvailabilityCheck();
|
const isFree = await runAvailabilityCheck();
|
||||||
if (!isFree) {
|
if (!isFree) {
|
||||||
window.alert('Выберите свободный логин');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +99,8 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.password = passwordInput.value;
|
state.registrationDraft.password = passwordInput.value;
|
||||||
|
|
||||||
if (!state.registrationDraft.password) {
|
if (!state.registrationDraft.password) {
|
||||||
window.alert('Введите пароль');
|
formError.textContent = 'Введите пароль.';
|
||||||
|
formError.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
|
|
||||||
@ -31,6 +32,14 @@ export function render({ navigate }) {
|
|||||||
question.className = 'auth-copy';
|
question.className = 'auth-copy';
|
||||||
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
|
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');
|
const rootToggle = document.createElement('input');
|
||||||
rootToggle.type = 'checkbox';
|
rootToggle.type = 'checkbox';
|
||||||
rootToggle.checked = state.keyStorage.saveRoot;
|
rootToggle.checked = state.keyStorage.saveRoot;
|
||||||
@ -46,17 +55,17 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const rootRow = document.createElement('label');
|
const rootRow = document.createElement('label');
|
||||||
rootRow.className = 'checkbox-row';
|
rootRow.className = 'checkbox-row';
|
||||||
rootRow.append(rootToggle, document.createTextNode('root key'));
|
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
|
||||||
|
|
||||||
const blockchainRow = document.createElement('label');
|
const blockchainRow = document.createElement('label');
|
||||||
blockchainRow.className = 'checkbox-row';
|
blockchainRow.className = 'checkbox-row';
|
||||||
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
|
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
|
||||||
|
|
||||||
const deviceRow = document.createElement('label');
|
const deviceRow = document.createElement('label');
|
||||||
deviceRow.className = 'checkbox-row';
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -72,6 +81,7 @@ export function render({ navigate }) {
|
|||||||
okButton.type = 'button';
|
okButton.type = 'button';
|
||||||
okButton.textContent = 'OK';
|
okButton.textContent = 'OK';
|
||||||
okButton.addEventListener('click', async () => {
|
okButton.addEventListener('click', async () => {
|
||||||
|
status.style.display = 'none';
|
||||||
try {
|
try {
|
||||||
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
||||||
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
||||||
@ -117,11 +127,15 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingSessionMaterial = null;
|
state.registrationDraft.pendingSessionMaterial = null;
|
||||||
|
|
||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
|
setAuthInfo(isLoginFlow
|
||||||
|
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||||
|
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||||
navigate('profile-view');
|
navigate('profile-view');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(error.message);
|
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||||
window.alert(error.message);
|
setAuthError(message);
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.display = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
refreshRegistrationBalance,
|
refreshRegistrationBalance,
|
||||||
@ -6,9 +6,25 @@ import {
|
|||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -16,6 +32,10 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
card.className = 'card stack';
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.style.display = 'none';
|
||||||
|
|
||||||
const walletValue = document.createElement('input');
|
const walletValue = document.createElement('input');
|
||||||
walletValue.className = 'input';
|
walletValue.className = 'input';
|
||||||
walletValue.type = 'text';
|
walletValue.type = 'text';
|
||||||
@ -39,7 +59,9 @@ export function render({ navigate }) {
|
|||||||
copyButton.textContent = 'Скопировать номер';
|
copyButton.textContent = 'Скопировать номер';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch {
|
} 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.type = 'button';
|
||||||
submitButton.textContent = 'Зарегистрироваться';
|
submitButton.textContent = 'Зарегистрироваться';
|
||||||
submitButton.addEventListener('click', async () => {
|
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 {
|
try {
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
submitButton.textContent = 'Регистрация...';
|
submitButton.textContent = 'Регистрация...';
|
||||||
@ -85,12 +125,14 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||||
|
|
||||||
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
|
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
|
||||||
window.alert('Отлично, вы зарегистрировались');
|
|
||||||
navigate('registration-keys-view');
|
navigate('registration-keys-view');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(error.message);
|
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
|
||||||
window.alert(error.message);
|
setAuthError(message);
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.display = '';
|
||||||
} finally {
|
} finally {
|
||||||
submitButton.disabled = false;
|
submitButton.disabled = false;
|
||||||
submitButton.textContent = 'Зарегистрироваться';
|
submitButton.textContent = 'Зарегистрироваться';
|
||||||
@ -106,7 +148,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
card.children[1].append(walletRow);
|
card.children[1].append(walletRow);
|
||||||
card.children[2].append(balanceRow);
|
card.children[2].append(balanceRow);
|
||||||
card.append(topupButton, submitButton);
|
card.append(topupButton, submitButton, status);
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
|
|||||||
@ -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 };
|
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
||||||
|
|
||||||
@ -40,6 +40,19 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
screen.append(logo, title);
|
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) {
|
if (state.startHint) {
|
||||||
const notice = document.createElement('div');
|
const notice = document.createElement('div');
|
||||||
notice.className = 'card auth-status-card';
|
notice.className = 'card auth-status-card';
|
||||||
|
|||||||
@ -19,16 +19,51 @@ export function getRoute() {
|
|||||||
return { pageId: '', params: {} };
|
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') {
|
if (pageId === 'chat-view') {
|
||||||
return { pageId, params: { chatId: dynamicId || '' } };
|
return { pageId, params: { chatId: dynamicId || '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channel-view') {
|
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 || '' } };
|
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') {
|
if (pageId === 'device-session-view') {
|
||||||
return { pageId, params: { sessionId: dynamicId || '' } };
|
return { pageId, params: { sessionId: dynamicId || '' } };
|
||||||
}
|
}
|
||||||
@ -57,6 +92,6 @@ export function resolveToolbarActive(pageId) {
|
|||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||||
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
|
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view') return 'channels-list';
|
||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,12 @@ import {
|
|||||||
signBase64,
|
signBase64,
|
||||||
utf8Bytes,
|
utf8Bytes,
|
||||||
} from './crypto-utils.js';
|
} from './crypto-utils.js';
|
||||||
|
import {
|
||||||
|
channelNameErrorText,
|
||||||
|
normalizeChannelDisplayName,
|
||||||
|
toCanonicalChannelSlug,
|
||||||
|
validateChannelDisplayName,
|
||||||
|
} from './channel-name-rules.js';
|
||||||
import {
|
import {
|
||||||
loadEncryptedUserSecrets,
|
loadEncryptedUserSecrets,
|
||||||
loadSessionMaterial,
|
loadSessionMaterial,
|
||||||
@ -20,20 +26,50 @@ import {
|
|||||||
} from './key-vault.js';
|
} from './key-vault.js';
|
||||||
|
|
||||||
const BCH_SUFFIX = '001';
|
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) {
|
function normalizeServerUrl(url) {
|
||||||
const value = (url || '').trim();
|
const value = (url || '').trim();
|
||||||
if (!value) return 'wss://shineup.me/ws';
|
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://')) {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function opError(op, response) {
|
function opError(op, response) {
|
||||||
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
|
const payload = response?.payload || {};
|
||||||
const code = response?.payload?.code || response?.code || 'UNKNOWN';
|
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})`);
|
const error = new Error(`${op}: ${message} (${code})`);
|
||||||
error.op = op;
|
error.op = op;
|
||||||
error.code = code;
|
error.code = code;
|
||||||
@ -51,11 +87,21 @@ function hexToBytes(hex) {
|
|||||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||||
const out = new Uint8Array(clean.length / 2);
|
const out = new Uint8Array(clean.length / 2);
|
||||||
for (let i = 0; i < out.length; i += 1) {
|
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;
|
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) {
|
function concatBytes(...chunks) {
|
||||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
const out = new Uint8Array(total);
|
const out = new Uint8Array(total);
|
||||||
@ -81,6 +127,12 @@ function int16Bytes(value) {
|
|||||||
return bytes;
|
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) {
|
function int64Bytes(value) {
|
||||||
const bytes = new Uint8Array(8);
|
const bytes = new Uint8Array(8);
|
||||||
const view = new DataView(bytes.buffer);
|
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 {
|
export class AuthService {
|
||||||
constructor(serverUrl) {
|
constructor(serverUrl) {
|
||||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
this.ws = new WsJsonClient(this.serverUrl);
|
||||||
|
this.headerHashCache = new Map();
|
||||||
|
this.writeLocks = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconnect(serverUrl) {
|
async reconnect(serverUrl) {
|
||||||
@ -119,6 +340,20 @@ export class AuthService {
|
|||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.serverUrl = normalized;
|
this.serverUrl = normalized;
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
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) {
|
async getUser(login) {
|
||||||
@ -293,18 +528,440 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChannelMessages(channel, limit = 200, sort = 'asc') {
|
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
||||||
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
|
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);
|
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
|
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
||||||
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
|
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);
|
if (response.status !== 200) throw opError('GetMessageThread', response);
|
||||||
return response.payload || {};
|
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) {
|
onEvent(op, handler) {
|
||||||
return this.ws.onEvent(op, handler);
|
return this.ws.onEvent(op, handler);
|
||||||
|
|||||||
79
shine-UI/js/services/channel-name-rules.js
Normal file
79
shine-UI/js/services/channel-name-rules.js
Normal file
@ -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 'Некорректное название канала.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,4 +1,21 @@
|
|||||||
const encoder = new TextEncoder();
|
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) {
|
function base64UrlToBase64(value) {
|
||||||
@ -8,7 +25,7 @@ function base64UrlToBase64(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function randomBase64(byteLen = 32) {
|
export function randomBase64(byteLen = 32) {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
|
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
||||||
return bytesToBase64(bytes);
|
return bytesToBase64(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +52,7 @@ export function utf8Bytes(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sha256Bytes(bytes) {
|
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);
|
return new Uint8Array(digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,8 +82,9 @@ function ed25519Pkcs8FromSeed(seed32) {
|
|||||||
export async function deriveEd25519FromPassword(password, suffix) {
|
export async function deriveEd25519FromPassword(password, suffix) {
|
||||||
const seed = await derivePasswordSeed(password, suffix);
|
const seed = await derivePasswordSeed(password, suffix);
|
||||||
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
||||||
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
const subtle = getSubtleApi();
|
||||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
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');
|
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -77,7 +95,8 @@ export async function deriveEd25519FromPassword(password, suffix) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||||
const baseKey = await crypto.subtle.importKey(
|
const subtle = getSubtleApi();
|
||||||
|
const baseKey = await subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
utf8Bytes(storagePwd),
|
utf8Bytes(storagePwd),
|
||||||
{ name: 'PBKDF2' },
|
{ name: 'PBKDF2' },
|
||||||
@ -85,7 +104,7 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
|||||||
['deriveKey'],
|
['deriveKey'],
|
||||||
);
|
);
|
||||||
|
|
||||||
return crypto.subtle.deriveKey(
|
return subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: 'PBKDF2',
|
name: 'PBKDF2',
|
||||||
salt: saltBytes,
|
salt: saltBytes,
|
||||||
@ -103,11 +122,13 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptJsonWithStoragePwd(value, storagePwd) {
|
export async function encryptJsonWithStoragePwd(value, storagePwd) {
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
const cryptoApi = getCryptoApi();
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const subtle = getSubtleApi();
|
||||||
|
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
|
||||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||||
const plainBytes = utf8Bytes(JSON.stringify(value));
|
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 {
|
return {
|
||||||
saltB64: bytesToBase64(salt),
|
saltB64: bytesToBase64(salt),
|
||||||
@ -121,35 +142,35 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
|||||||
const iv = base64ToBytes(envelope.ivB64);
|
const iv = base64ToBytes(envelope.ivB64);
|
||||||
const cipher = base64ToBytes(envelope.cipherB64);
|
const cipher = base64ToBytes(envelope.cipherB64);
|
||||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
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);
|
const text = new TextDecoder().decode(plain);
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateEd25519Pair() {
|
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) {
|
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));
|
return bytesToBase64(new Uint8Array(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPkcs8B64(privateKey) {
|
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));
|
return bytesToBase64(new Uint8Array(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importPkcs8Ed25519(pkcs8B64) {
|
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) {
|
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));
|
return bytesToBase64(new Uint8Array(signature));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signBytes(privateKey, bytes) {
|
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);
|
return new Uint8Array(signature);
|
||||||
}
|
}
|
||||||
|
|||||||
112
shine-UI/js/services/ui-error-texts.js
Normal file
112
shine-UI/js/services/ui-error-texts.js
Normal 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;
|
||||||
|
}
|
||||||
@ -5,12 +5,52 @@ const DEFAULT_TIMEOUT_MS = 12000;
|
|||||||
function buildWsUrl(raw) {
|
function buildWsUrl(raw) {
|
||||||
const value = (raw || '').trim();
|
const value = (raw || '').trim();
|
||||||
if (!value) return 'wss://shineup.me/ws';
|
if (!value) return 'wss://shineup.me/ws';
|
||||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
if (value.startsWith('/')) {
|
||||||
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
|
const secure = window.location.protocol === 'https:';
|
||||||
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
|
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;
|
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) {
|
function createRequestId(op) {
|
||||||
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
}
|
}
|
||||||
@ -27,6 +67,15 @@ export class WsJsonClient {
|
|||||||
async open() {
|
async open() {
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||||
if (this.openPromise) return this.openPromise;
|
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) => {
|
this.openPromise = new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(this.url);
|
const ws = new WebSocket(this.url);
|
||||||
@ -54,7 +103,6 @@ export class WsJsonClient {
|
|||||||
});
|
});
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.openPromise = null;
|
this.openPromise = null;
|
||||||
this.eventListeners = new Map();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.openPromise;
|
return this.openPromise;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { clearClientAuthData } from './services/key-vault.js';
|
|||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
||||||
const INVALID_SESSION_CODES = new Set([
|
const INVALID_SESSION_CODES = new Set([
|
||||||
'NOT_AUTHENTICATED',
|
'NOT_AUTHENTICATED',
|
||||||
'SESSION_NOT_FOUND',
|
'SESSION_NOT_FOUND',
|
||||||
@ -13,18 +14,63 @@ const INVALID_SESSION_CODES = new Set([
|
|||||||
|
|
||||||
function readLocalWsOverrideUrl() {
|
function readLocalWsOverrideUrl() {
|
||||||
try {
|
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);
|
const asNum = Number(value);
|
||||||
if (!Number.isFinite(asNum)) return '';
|
if (!Number.isFinite(asNum)) return '';
|
||||||
const port = Math.trunc(asNum);
|
const port = Math.trunc(asNum);
|
||||||
if (port <= 0 || port > 65535) return '';
|
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 {
|
} catch {
|
||||||
return '';
|
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';
|
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
|
||||||
|
|
||||||
function loadStoredSession() {
|
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) {
|
function persistSession(session) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||||
@ -55,6 +121,7 @@ function clearStoredSession() {
|
|||||||
|
|
||||||
function createInitialState({ withStoredSession = true } = {}) {
|
function createInitialState({ withStoredSession = true } = {}) {
|
||||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||||
|
const storedReactions = loadStoredReactions();
|
||||||
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -120,6 +187,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
channelsFeed: null,
|
channelsFeed: null,
|
||||||
channelsIndex: {},
|
channelsIndex: {},
|
||||||
localChannelPosts: {},
|
localChannelPosts: {},
|
||||||
|
messageReactions: storedReactions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,6 +315,10 @@ function resetStateForSignedOut() {
|
|||||||
state.deviceConnect = next.deviceConnect;
|
state.deviceConnect = next.deviceConnect;
|
||||||
state.authUi = next.authUi;
|
state.authUi = next.authUi;
|
||||||
state.sessions = next.sessions;
|
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 = '' } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
||||||
@ -295,3 +367,31 @@ export function addLocalChannelPost(channelId, post) {
|
|||||||
body: text,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -31,11 +31,16 @@
|
|||||||
.icon-btn,
|
.icon-btn,
|
||||||
.text-btn,
|
.text-btn,
|
||||||
.primary-btn,
|
.primary-btn,
|
||||||
|
.secondary-btn,
|
||||||
|
.destructive-btn,
|
||||||
.ghost-btn {
|
.ghost-btn {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--card-soft);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
padding: 8px 10px;
|
color: var(--text);
|
||||||
|
padding: 9px 12px;
|
||||||
|
min-height: 38px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
}
|
}
|
||||||
@ -43,16 +48,49 @@
|
|||||||
.icon-btn:hover,
|
.icon-btn:hover,
|
||||||
.text-btn:hover,
|
.text-btn:hover,
|
||||||
.primary-btn:hover,
|
.primary-btn:hover,
|
||||||
|
.secondary-btn:hover,
|
||||||
|
.destructive-btn:hover,
|
||||||
.ghost-btn:hover {
|
.ghost-btn:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-btn {
|
.primary-btn {
|
||||||
background: linear-gradient(120deg, var(--accent-soft), rgba(82, 120, 240, 0.22));
|
background: linear-gradient(120deg, rgba(218, 179, 87, 0.95), rgba(184, 137, 54, 0.92));
|
||||||
|
border-color: rgba(242, 210, 129, 0.72);
|
||||||
|
color: #101426;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: linear-gradient(180deg, rgba(19, 35, 63, 0.9), rgba(14, 26, 50, 0.95));
|
||||||
|
border-color: rgba(132, 162, 228, 0.42);
|
||||||
|
color: #e7efff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destructive-btn {
|
||||||
|
background: linear-gradient(120deg, rgba(129, 37, 54, 0.9), rgba(92, 23, 37, 0.94));
|
||||||
|
border-color: rgba(255, 129, 147, 0.46);
|
||||||
|
color: #ffe4ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-btn {
|
.ghost-btn {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(17, 26, 46, 0.62);
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled,
|
||||||
|
.text-btn:disabled,
|
||||||
|
.primary-btn:disabled,
|
||||||
|
.secondary-btn:disabled,
|
||||||
|
.destructive-btn:disabled,
|
||||||
|
.ghost-btn:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
color: #93a6cc;
|
||||||
|
background: linear-gradient(180deg, rgba(18, 30, 54, 0.8), rgba(11, 20, 39, 0.86));
|
||||||
|
border-color: rgba(124, 145, 189, 0.28);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@ -580,6 +618,11 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
color: #f3f7ff;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
caret-color: #f1d18a;
|
||||||
|
-webkit-text-fill-color: #f3f7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
@ -587,6 +630,45 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: rgba(190, 206, 238, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.input:-webkit-autofill,
|
||||||
|
input.input:-webkit-autofill:hover,
|
||||||
|
input.input:-webkit-autofill:focus,
|
||||||
|
textarea.input:-webkit-autofill,
|
||||||
|
textarea.input:-webkit-autofill:hover,
|
||||||
|
textarea.input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #f3f7ff !important;
|
||||||
|
caret-color: #f1d18a;
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: 0 0 0 1000px rgba(11, 24, 46, 0.96) inset !important;
|
||||||
|
transition: background-color 9999s ease-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #f1d69a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.small-btn {
|
.small-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -787,6 +869,10 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen .modal-card .input {
|
||||||
|
background: linear-gradient(180deg, rgba(16, 31, 58, 0.9), rgba(11, 22, 43, 0.94));
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -821,3 +907,615 @@
|
|||||||
background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15));
|
background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% -120%, rgba(228, 186, 94, 0.28), transparent 48%),
|
||||||
|
linear-gradient(160deg, rgba(14, 25, 47, 0.98), rgba(7, 16, 34, 0.98));
|
||||||
|
border: 1px solid rgba(197, 160, 85, 0.38);
|
||||||
|
box-shadow: 0 18px 32px rgba(2, 6, 13, 0.62);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
color: #b8c7ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 52px;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.active {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(220, 181, 94, 0.32), rgba(39, 66, 122, 0.3)),
|
||||||
|
rgba(20, 35, 64, 0.62);
|
||||||
|
border: 1px solid rgba(220, 183, 100, 0.44);
|
||||||
|
color: #f7e2ad;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 242, 204, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: linear-gradient(165deg, rgba(16, 31, 58, 0.97), rgba(10, 18, 36, 0.97));
|
||||||
|
border-color: rgba(216, 179, 93, 0.4);
|
||||||
|
box-shadow: 0 20px 38px rgba(2, 6, 12, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen {
|
||||||
|
position: relative;
|
||||||
|
gap: 11px;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -24px -18px 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 16% -4%, rgba(232, 191, 92, 0.22), transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 6%, rgba(77, 114, 189, 0.35), transparent 39%),
|
||||||
|
radial-gradient(circle at 52% 22%, rgba(13, 33, 70, 0.54), transparent 44%),
|
||||||
|
linear-gradient(180deg, rgba(4, 11, 24, 0.92), rgba(4, 11, 23, 0.4) 46%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .page-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .page-header .icon-btn,
|
||||||
|
.channels-screen .page-header .text-btn {
|
||||||
|
background: linear-gradient(180deg, rgba(20, 37, 67, 0.9), rgba(13, 24, 47, 0.94));
|
||||||
|
border-color: rgba(146, 173, 229, 0.38);
|
||||||
|
color: #d9e6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .page-title {
|
||||||
|
font-size: 29px;
|
||||||
|
line-height: 1.05;
|
||||||
|
font-family: "Cormorant Garamond", "Times New Roman", serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: #f5db9e;
|
||||||
|
text-shadow: 0 10px 20px rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .page-title {
|
||||||
|
font-size: 37px;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .card {
|
||||||
|
background:
|
||||||
|
linear-gradient(168deg, rgba(16, 31, 58, 0.95), rgba(8, 17, 34, 0.98)),
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(53, 90, 165, 0.2), transparent 52%);
|
||||||
|
border: 1px solid rgba(176, 144, 74, 0.3);
|
||||||
|
box-shadow: 0 14px 30px rgba(2, 7, 15, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .meta-muted {
|
||||||
|
color: #c3cfea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .status-line.is-unavailable {
|
||||||
|
color: #ffd9e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-color: rgba(214, 177, 95, 0.42);
|
||||||
|
box-shadow: 0 14px 30px rgba(4, 9, 20, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-emblem {
|
||||||
|
position: relative;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(220, 182, 98, 0.64);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 45% 40%, rgba(247, 217, 145, 0.9), rgba(199, 146, 61, 0.95) 46%, rgba(127, 88, 36, 0.96) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 5px rgba(20, 38, 74, 0.76),
|
||||||
|
0 10px 20px rgba(3, 7, 16, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-emblem::before,
|
||||||
|
.channels-hero-emblem::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(27, 47, 85, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-emblem::after {
|
||||||
|
inset: 7px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(228, 192, 109, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #e8cc8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-title {
|
||||||
|
font-family: "Cormorant Garamond", "Times New Roman", serif;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #f4ddab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-subtitle {
|
||||||
|
color: #afc1e3;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: #e9f0ff;
|
||||||
|
min-height: 46px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-user-chip::before {
|
||||||
|
content: "";
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ddb86f;
|
||||||
|
box-shadow: 0 0 0 6px rgba(221, 184, 111, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-help-card strong {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #f3d79c;
|
||||||
|
font-family: "Cormorant Garamond", "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-help-card .meta-muted {
|
||||||
|
line-height: 1.34;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-help-card {
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-info-strip {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
color: #ead3a0;
|
||||||
|
border: 1px solid rgba(214, 175, 89, 0.4);
|
||||||
|
background: linear-gradient(134deg, rgba(180, 140, 62, 0.22), rgba(23, 44, 84, 0.3));
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-btn {
|
||||||
|
min-height: 50px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-btn.primary-btn {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 247, 214, 0.6), 0 10px 18px rgba(5, 8, 16, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-btn.destructive-btn {
|
||||||
|
border-color: rgba(248, 141, 163, 0.5);
|
||||||
|
background: linear-gradient(120deg, rgba(126, 31, 52, 0.94), rgba(88, 22, 37, 0.95));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(171, 198, 255, 0.26), 0 9px 16px rgba(5, 8, 16, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-scroll-wrap {
|
||||||
|
max-height: none;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-groups {
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-section {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-section .section-title {
|
||||||
|
font-size: 21px;
|
||||||
|
color: #f2d395;
|
||||||
|
margin: 0 2px;
|
||||||
|
font-family: "Cormorant Garamond", "Times New Roman", serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list-empty {
|
||||||
|
border: 1px dashed rgba(209, 172, 87, 0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
color: #aeb9d8;
|
||||||
|
background: rgba(10, 18, 36, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-divider {
|
||||||
|
border-top-color: rgba(211, 170, 86, 0.22);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr) 72px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 13px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(193, 157, 82, 0.28);
|
||||||
|
background:
|
||||||
|
linear-gradient(150deg, rgba(16, 31, 58, 0.9), rgba(10, 20, 40, 0.94)),
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(72, 106, 179, 0.22), transparent 46%);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row:hover {
|
||||||
|
border-color: rgba(224, 188, 106, 0.52);
|
||||||
|
box-shadow: 0 10px 22px rgba(2, 8, 16, 0.44);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row .avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
background: linear-gradient(138deg, #efd193, #c18c42);
|
||||||
|
color: #17233f;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 244, 208, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-title {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #f5daa0;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9db0dc;
|
||||||
|
word-break: break-word;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-message {
|
||||||
|
color: #e5eeff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-owner {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bdcdeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-meta {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-kind {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(205, 166, 86, 0.42);
|
||||||
|
color: #f1d49a;
|
||||||
|
background: rgba(191, 149, 66, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #96a7ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-count {
|
||||||
|
min-width: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(140deg, rgba(223, 186, 98, 0.95), rgba(199, 155, 75, 0.95));
|
||||||
|
color: #17203a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-bottom-action {
|
||||||
|
margin-top: 6px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-status {
|
||||||
|
border-color: rgba(227, 127, 144, 0.45);
|
||||||
|
background: linear-gradient(165deg, rgba(68, 24, 36, 0.55), rgba(19, 17, 33, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #f0d089;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-family: "Cormorant Garamond", "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #aebddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e8d8b0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-feed {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-card {
|
||||||
|
gap: 9px;
|
||||||
|
padding: 13px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border-color: rgba(182, 149, 78, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f2dcab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-body {
|
||||||
|
color: #eef3ff;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: linear-gradient(170deg, rgba(22, 40, 73, 0.75), rgba(12, 25, 48, 0.78));
|
||||||
|
border: 1px solid rgba(116, 141, 193, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9fb2dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-actions .secondary-btn,
|
||||||
|
.thread-node-actions .secondary-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-summary {
|
||||||
|
color: #efd9a4;
|
||||||
|
border-color: rgba(212, 171, 90, 0.36);
|
||||||
|
background: linear-gradient(130deg, rgba(178, 137, 58, 0.2), rgba(24, 41, 74, 0.24));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-card {
|
||||||
|
gap: 9px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border-color: rgba(183, 150, 79, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-heading {
|
||||||
|
color: #f1dcab;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-meta {
|
||||||
|
color: #aebddd;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-body {
|
||||||
|
color: #eef3ff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: linear-gradient(170deg, rgba(22, 40, 73, 0.72), rgba(12, 25, 48, 0.78));
|
||||||
|
border: 1px solid rgba(116, 141, 193, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-stats {
|
||||||
|
color: #99acd6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-level {
|
||||||
|
margin-left: calc(min(var(--depth, 0), 4) * 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block {
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(151, 174, 221, 0.2);
|
||||||
|
background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--ancestors > .section-title {
|
||||||
|
color: #b9cbef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--ancestors {
|
||||||
|
border-color: rgba(141, 166, 214, 0.26);
|
||||||
|
background: linear-gradient(160deg, rgba(12, 28, 56, 0.73), rgba(7, 15, 32, 0.78));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--focus > .section-title {
|
||||||
|
color: #f0d9a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--focus {
|
||||||
|
border-color: rgba(214, 177, 95, 0.34);
|
||||||
|
background: linear-gradient(160deg, rgba(33, 44, 72, 0.68), rgba(12, 20, 36, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--replies > .section-title {
|
||||||
|
color: #c8d6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-block--replies {
|
||||||
|
border-color: rgba(161, 186, 233, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-main-action {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-back-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-action-like.is-liked,
|
||||||
|
.thread-like-btn.is-liked {
|
||||||
|
background: linear-gradient(120deg, rgba(128, 39, 56, 0.92), rgba(92, 26, 39, 0.94));
|
||||||
|
border-color: rgba(250, 145, 165, 0.54);
|
||||||
|
color: #ffe5ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-action-reply,
|
||||||
|
.thread-reply-btn {
|
||||||
|
border-color: rgba(152, 181, 240, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-action-thread {
|
||||||
|
border-color: rgba(216, 178, 95, 0.5);
|
||||||
|
color: #f3ddac;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.channels-screen .page-title {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .page-title {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-hero-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-btn {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) 64px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row .avatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-message {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-actions {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 365px) {
|
||||||
|
.channels-screen .page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -7,9 +7,11 @@ body {
|
|||||||
width: min(100vw, 430px);
|
width: min(100vw, 430px);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99));
|
background:
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
radial-gradient(circle at 16% -8%, rgba(211, 168, 76, 0.16), transparent 38%),
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
linear-gradient(165deg, rgba(10, 21, 44, 0.98), rgba(5, 11, 24, 0.99));
|
||||||
|
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
|
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -36,7 +38,7 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
background: linear-gradient(180deg, rgba(10, 14, 23, 0) 0%, rgba(10, 14, 23, 0.95) 42%);
|
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-0: #080b12;
|
--bg-0: #050c1a;
|
||||||
--bg-1: #101624;
|
--bg-1: #0a1630;
|
||||||
--bg-2: #171f32;
|
--bg-2: #132346;
|
||||||
--card: #1a2436;
|
--card: #162646;
|
||||||
--card-soft: #202d45;
|
--card-soft: #1a2f55;
|
||||||
--line: #2a3854;
|
--line: #2f4777;
|
||||||
--text: #ebf1ff;
|
--text: #edf2ff;
|
||||||
--text-muted: #99a8cb;
|
--text-muted: #9eb0d8;
|
||||||
--accent: #53d8fb;
|
--accent: #d9b56f;
|
||||||
--accent-soft: rgba(83, 216, 251, 0.17);
|
--accent-soft: rgba(217, 181, 111, 0.18);
|
||||||
--danger: #ff718f;
|
--danger: #ff718f;
|
||||||
--ok: #84f4a1;
|
--ok: #84f4a1;
|
||||||
--radius-lg: 18px;
|
--radius-lg: 18px;
|
||||||
@ -27,7 +27,10 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: radial-gradient(circle at 22% -10%, #1f355e 0%, var(--bg-0) 45%) fixed;
|
background:
|
||||||
|
radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%),
|
||||||
|
radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%),
|
||||||
|
linear-gradient(180deg, #050b18, #030812 70%) fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-main);
|
font-family: var(--font-main);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,8 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/** Лайк (LIKE). */
|
/** Лайк (LIKE). */
|
||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
|
/** Снятие лайка (UNLIKE). */
|
||||||
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|
||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import java.nio.ByteBuffer;
|
|||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,6 +40,10 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
||||||
|
|
||||||
private static final byte[] ZERO32 = new byte[32];
|
private static final byte[] ZERO32 = new byte[32];
|
||||||
|
private static final int MIN_NAME_LENGTH = 3;
|
||||||
|
private static final int MAX_NAME_LENGTH = 32;
|
||||||
|
private static final Pattern ALLOWED_NAME_PATTERN =
|
||||||
|
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType; // из header
|
||||||
public final short version; // из header
|
public final short version; // из header
|
||||||
@ -121,14 +126,15 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
|
||||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
||||||
|
|
||||||
if (channelName == null || channelName.isBlank())
|
String normalizedName = normalizeDisplayName(channelName);
|
||||||
|
if (normalizedName.isEmpty())
|
||||||
throw new IllegalArgumentException("channelName is blank");
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
|
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
|
||||||
if (!channelName.matches("^[A-Za-z0-9_]+$"))
|
// Backward compatibility for historical blocks:
|
||||||
throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$");
|
// strict create-channel rules are enforced in AddBlock handler (ChannelNameRules),
|
||||||
|
// but parser-level check must allow legacy channel names during bootstrap/replay.
|
||||||
if ("0".equals(channelName))
|
if (cpLen > MAX_NAME_LENGTH)
|
||||||
throw new IllegalArgumentException("channelName \"0\" is reserved");
|
throw new IllegalArgumentException("channelName length must be <=32");
|
||||||
|
|
||||||
// tech-line: prev обязателен (минимум HEADER=0)
|
// tech-line: prev обязателен (минимум HEADER=0)
|
||||||
if (prevLineNumber < 0)
|
if (prevLineNumber < 0)
|
||||||
@ -141,6 +147,11 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeDisplayName(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
|
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import java.util.Objects;
|
|||||||
*
|
*
|
||||||
* subType (в заголовке блока):
|
* subType (в заголовке блока):
|
||||||
* 1 = LIKE
|
* 1 = LIKE
|
||||||
|
* 2 = UNLIKE
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат:
|
* bodyBytes (BigEndian), новый формат:
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toBlockchainNameLen (uint8)
|
||||||
@ -45,7 +46,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
|
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||||
}
|
}
|
||||||
if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) {
|
if (!isSupportedSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +89,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ReactionBody check() {
|
public ReactionBody check() {
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF))
|
if (!isSupportedSubType(subType))
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||||
@ -123,4 +124,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toBchName() { return toBlockchainName; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
private static boolean isSupportedSubType(short subType) {
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
|
return st == (MsgSubType.REACTION_LIKE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -36,6 +36,7 @@ public final class DatabaseInitializer {
|
|||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|
||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_FRIEND = 10;
|
||||||
@ -273,12 +274,12 @@ public final class DatabaseInitializer {
|
|||||||
rel_type INTEGER NOT NULL,
|
rel_type INTEGER NOT NULL,
|
||||||
to_login TEXT NOT NULL,
|
to_login TEXT NOT NULL,
|
||||||
to_bch_name TEXT NOT NULL,
|
to_bch_name TEXT NOT NULL,
|
||||||
to_block_number INTEGER,
|
to_block_number INTEGER NOT NULL,
|
||||||
to_block_hash BLOB,
|
to_block_hash BLOB NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||||
|
|
||||||
UNIQUE (login, rel_type, to_login)
|
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||||
);
|
);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
@ -297,6 +298,11 @@ public final class DatabaseInitializer {
|
|||||||
ON connections_state (login, to_login);
|
ON connections_state (login, to_login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||||
|
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||||
|
""");
|
||||||
|
|
||||||
// 8) message_stats
|
// 8) message_stats
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS message_stats (
|
CREATE TABLE IF NOT EXISTS message_stats (
|
||||||
@ -328,7 +334,69 @@ public final class DatabaseInitializer {
|
|||||||
ON message_stats (to_login);
|
ON message_stats (to_login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 9) direct_messages
|
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||||
|
from_login TEXT NOT NULL,
|
||||||
|
from_bch_name TEXT NOT NULL,
|
||||||
|
reaction_type INTEGER NOT NULL,
|
||||||
|
to_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
last_sub_type INTEGER NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE (
|
||||||
|
from_login,
|
||||||
|
from_bch_name,
|
||||||
|
reaction_type,
|
||||||
|
to_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash
|
||||||
|
)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||||
|
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||||
|
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// 9) channel_names_state (global normalized channel names)
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
|
slug TEXT NOT NULL PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
owner_bch_name TEXT NOT NULL,
|
||||||
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
|
channel_root_block_hash BLOB NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
||||||
|
ON channel_names_state (slug);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||||
|
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||||
|
ON channel_names_state (owner_login, owner_bch_name);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// 10) direct_messages
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
message_id TEXT NOT NULL PRIMARY KEY,
|
message_id TEXT NOT NULL PRIMARY KEY,
|
||||||
@ -351,7 +419,7 @@ public final class DatabaseInitializer {
|
|||||||
ON direct_messages (from_login, created_at_ms);
|
ON direct_messages (from_login, created_at_ms);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 10) user_push_tokens
|
// 11) user_push_tokens
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||||
token_id TEXT NOT NULL PRIMARY KEY,
|
token_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
|||||||
@ -1,52 +1,55 @@
|
|||||||
package shine.db;
|
package shine.db;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
||||||
*
|
*
|
||||||
* Мы специально сделали триггеры максимально "совместимыми":
|
* Мы специально сделали триггеры максимально "совместимыми":
|
||||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
||||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
||||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
||||||
* INSERT OR IGNORE + UPDATE
|
* INSERT OR IGNORE + UPDATE
|
||||||
* (Старые SQLite не знают UPSERT.)
|
* (Старые SQLite не знают UPSERT.)
|
||||||
*
|
*
|
||||||
* =============================================================================
|
* =============================================================================
|
||||||
* ОПИСАНИЕ ТРИГГЕРОВ
|
* РћРџРРЎРђРќРР• РўР РГГЕРОВ
|
||||||
* =============================================================================
|
* =============================================================================
|
||||||
*
|
*
|
||||||
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
||||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
||||||
*
|
*
|
||||||
* Зачем это нужно:
|
* Зачем это нужно:
|
||||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
||||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
||||||
* и подтверждает, что ссылка не подменена.
|
* и подтверждает, что ссылка не подменена.
|
||||||
*
|
*
|
||||||
* Когда срабатывает:
|
* Когда срабатывает:
|
||||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
||||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
||||||
*
|
*
|
||||||
* Что проверяет:
|
* Что проверяет:
|
||||||
* A) line-поля допускаются только для msg_type:
|
* A) line-поля допускаются только для msg_type:
|
||||||
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
||||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
||||||
* C) prev-блок линии существует в той же цепочке bch_name
|
* C) prev-блок линии существует в той же цепочке bch_name
|
||||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
* D) prev_hash совпадает с block_hash найденного prev-блока
|
||||||
* E) line_code корректный:
|
* E) line_code корректный:
|
||||||
* - либо первый шаг после root: prev_line_number == line_code
|
* - либо первый шаг после root: prev_line_number == line_code
|
||||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
||||||
* F) this_line_number:
|
* F) this_line_number:
|
||||||
* - первый шаг после root:
|
* - первый шаг после root:
|
||||||
* TEXT: this_line_number = 0
|
* TEXT: this_line_number = 0
|
||||||
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
||||||
* - обычный шаг:
|
* - обычный шаг:
|
||||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
||||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
||||||
*
|
*
|
||||||
* Какие ошибки кидает:
|
* Какие ошибки кидает:
|
||||||
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
||||||
* - LINE_ERR_PARTIAL_FIELDS
|
* - LINE_ERR_PARTIAL_FIELDS
|
||||||
* - LINE_ERR_NO_PREV
|
* - LINE_ERR_NO_PREV
|
||||||
@ -56,28 +59,30 @@ import java.sql.Statement;
|
|||||||
* - LINE_ERR_THIS_LINE_BAD_STEP
|
* - LINE_ERR_THIS_LINE_BAD_STEP
|
||||||
*
|
*
|
||||||
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
||||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
||||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
||||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
||||||
*
|
*
|
||||||
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
||||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
* Поддерживает likes_count в message_stats для цели (to_*).
|
||||||
*
|
*
|
||||||
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
||||||
* Поддерживает replies_count в message_stats.
|
* Поддерживает replies_count в message_stats.
|
||||||
*
|
*
|
||||||
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
||||||
* Логика edit:
|
* Логика edit:
|
||||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
||||||
* - увеличивает edits_count в message_stats
|
* - увеличивает edits_count в message_stats
|
||||||
*/
|
*/
|
||||||
public final class DatabaseTriggersInstaller {
|
public final class DatabaseTriggersInstaller {
|
||||||
|
|
||||||
private DatabaseTriggersInstaller() {}
|
private DatabaseTriggersInstaller() {}
|
||||||
|
|
||||||
public static void createAllTriggers(Statement st) throws SQLException {
|
public static void createAllTriggers(Statement st) throws SQLException {
|
||||||
// На всякий случай убираем старые "криво названные" триггеры,
|
dropTriggersByPrefix(st, "trg_blocks_");
|
||||||
// если они когда-то попадали в БД.
|
|
||||||
|
// На всякий случай убираем старые "криво названные" триггеры,
|
||||||
|
// если они когда-то попадали в БД.
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
||||||
|
|
||||||
@ -93,6 +98,24 @@ public final class DatabaseTriggersInstaller {
|
|||||||
createEditApplyTrigger(st);
|
createEditApplyTrigger(st);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
|
||||||
|
List<String> triggerNames = new ArrayList<>();
|
||||||
|
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
|
||||||
|
try (ResultSet rs = st.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
triggerNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String name : triggerNames) {
|
||||||
|
String safeName = name.replace("\"", "\"\"");
|
||||||
|
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
||||||
@ -182,7 +205,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
WHEN NEW.msg_type = 3
|
WHEN NEW.msg_type = 3
|
||||||
BEGIN
|
BEGIN
|
||||||
-- FRIEND/CONTACT/FOLLOW:
|
-- FRIEND/CONTACT/FOLLOW:
|
||||||
-- 1) если записи нет — создаём
|
-- 1) если записи нет — создаём
|
||||||
INSERT OR IGNORE INTO connections_state (
|
INSERT OR IGNORE INTO connections_state (
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
)
|
)
|
||||||
@ -197,7 +220,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND NEW.to_login IS NOT NULL
|
AND NEW.to_login IS NOT NULL
|
||||||
AND NEW.to_bch_name IS NOT NULL;
|
AND NEW.to_bch_name IS NOT NULL;
|
||||||
|
|
||||||
-- 2) если запись есть — обновляем актуальные to_*
|
-- 2) если запись есть — обновляем актуальные to_*
|
||||||
UPDATE connections_state
|
UPDATE connections_state
|
||||||
SET
|
SET
|
||||||
to_bch_name = NEW.to_bch_name,
|
to_bch_name = NEW.to_bch_name,
|
||||||
@ -211,7 +234,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND NEW.to_bch_name IS NOT NULL;
|
AND NEW.to_bch_name IS NOT NULL;
|
||||||
|
|
||||||
-- UNFRIEND/UNCONTACT/UNFOLLOW:
|
-- UNFRIEND/UNCONTACT/UNFOLLOW:
|
||||||
-- удаляем соответствующее "позитивное" состояние
|
-- удаляем соответствующее "позитивное" состояние
|
||||||
DELETE FROM connections_state
|
DELETE FROM connections_state
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
AND to_login = NEW.to_login
|
AND to_login = NEW.to_login
|
||||||
@ -237,13 +260,14 @@ public final class DatabaseTriggersInstaller {
|
|||||||
|
|
||||||
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
||||||
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
||||||
|
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
|
||||||
|
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
||||||
AFTER INSERT ON blocks
|
AFTER INSERT ON blocks
|
||||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type = %d
|
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
|
||||||
BEGIN
|
BEGIN
|
||||||
-- создаём строку, если её не было
|
-- ensure target stats row exists
|
||||||
INSERT OR IGNORE INTO message_stats (
|
INSERT OR IGNORE INTO message_stats (
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||||
likes_count, replies_count, edits_count
|
likes_count, replies_count, edits_count
|
||||||
@ -256,9 +280,48 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND NEW.to_block_number IS NOT NULL
|
AND NEW.to_block_number IS NOT NULL
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
AND NEW.to_block_hash IS NOT NULL;
|
||||||
|
|
||||||
-- +1 like
|
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
|
||||||
UPDATE message_stats
|
UPDATE message_stats
|
||||||
SET likes_count = likes_count + 1
|
SET likes_count = MAX(
|
||||||
|
0,
|
||||||
|
likes_count + (
|
||||||
|
CASE
|
||||||
|
WHEN NEW.msg_sub_type = %d
|
||||||
|
AND COALESCE((
|
||||||
|
SELECT b.msg_sub_type
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.login = NEW.login
|
||||||
|
AND b.bch_name = NEW.bch_name
|
||||||
|
AND b.msg_type = 2
|
||||||
|
AND b.to_login = NEW.to_login
|
||||||
|
AND b.to_bch_name = NEW.to_bch_name
|
||||||
|
AND b.to_block_number = NEW.to_block_number
|
||||||
|
AND b.to_block_hash = NEW.to_block_hash
|
||||||
|
AND b.block_number < NEW.block_number
|
||||||
|
ORDER BY b.block_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
), -1) <> %d
|
||||||
|
THEN 1
|
||||||
|
WHEN NEW.msg_sub_type = %d
|
||||||
|
AND COALESCE((
|
||||||
|
SELECT b.msg_sub_type
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.login = NEW.login
|
||||||
|
AND b.bch_name = NEW.bch_name
|
||||||
|
AND b.msg_type = 2
|
||||||
|
AND b.to_login = NEW.to_login
|
||||||
|
AND b.to_bch_name = NEW.to_bch_name
|
||||||
|
AND b.to_block_number = NEW.to_block_number
|
||||||
|
AND b.to_block_hash = NEW.to_block_hash
|
||||||
|
AND b.block_number < NEW.block_number
|
||||||
|
ORDER BY b.block_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
), -1) = %d
|
||||||
|
THEN -1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE to_login = NEW.to_login
|
WHERE to_login = NEW.to_login
|
||||||
AND to_bch_name = NEW.to_bch_name
|
AND to_bch_name = NEW.to_bch_name
|
||||||
AND to_block_number = NEW.to_block_number
|
AND to_block_number = NEW.to_block_number
|
||||||
@ -267,8 +330,43 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND NEW.to_bch_name IS NOT NULL
|
AND NEW.to_bch_name IS NOT NULL
|
||||||
AND NEW.to_block_number IS NOT NULL
|
AND NEW.to_block_number IS NOT NULL
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
AND NEW.to_block_hash IS NOT NULL;
|
||||||
|
|
||||||
|
-- persist latest actor->target reaction state
|
||||||
|
INSERT OR IGNORE INTO reactions_state (
|
||||||
|
from_login, from_bch_name, reaction_type,
|
||||||
|
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||||
|
last_sub_type
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
NEW.login, NEW.bch_name, %d,
|
||||||
|
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||||
|
NEW.msg_sub_type
|
||||||
|
WHERE NEW.to_login IS NOT NULL
|
||||||
|
AND NEW.to_bch_name IS NOT NULL
|
||||||
|
AND NEW.to_block_number IS NOT NULL
|
||||||
|
AND NEW.to_block_hash IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE reactions_state
|
||||||
|
SET last_sub_type = NEW.msg_sub_type
|
||||||
|
WHERE from_login = NEW.login
|
||||||
|
AND from_bch_name = NEW.bch_name
|
||||||
|
AND reaction_type = %d
|
||||||
|
AND to_login = NEW.to_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = NEW.to_block_number
|
||||||
|
AND to_block_hash = NEW.to_block_hash
|
||||||
|
AND NEW.to_login IS NOT NULL
|
||||||
|
AND NEW.to_bch_name IS NOT NULL
|
||||||
|
AND NEW.to_block_number IS NOT NULL
|
||||||
|
AND NEW.to_block_hash IS NOT NULL;
|
||||||
END;
|
END;
|
||||||
""".formatted(LIKE));
|
""".formatted(
|
||||||
|
LIKE, UNLIKE,
|
||||||
|
LIKE, LIKE,
|
||||||
|
UNLIKE, LIKE,
|
||||||
|
LIKE,
|
||||||
|
LIKE
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
||||||
@ -314,7 +412,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AFTER INSERT ON blocks
|
AFTER INSERT ON blocks
|
||||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
||||||
BEGIN
|
BEGIN
|
||||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
||||||
UPDATE blocks
|
UPDATE blocks
|
||||||
SET edited_by_block_number = NEW.block_number
|
SET edited_by_block_number = NEW.block_number
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
@ -322,7 +420,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND block_number = NEW.to_block_number
|
AND block_number = NEW.to_block_number
|
||||||
AND NEW.to_block_number IS NOT NULL;
|
AND NEW.to_block_number IS NOT NULL;
|
||||||
|
|
||||||
-- 2) создаём stats-строку если её не было
|
-- 2) создаём stats-строку если её не было
|
||||||
INSERT OR IGNORE INTO message_stats (
|
INSERT OR IGNORE INTO message_stats (
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||||
likes_count, replies_count, edits_count
|
likes_count, replies_count, edits_count
|
||||||
@ -350,3 +448,4 @@ public final class DatabaseTriggersInstaller {
|
|||||||
""".formatted(EDIT_POST, EDIT_REPLY));
|
""".formatted(EDIT_POST, EDIT_REPLY));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,8 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/** Лайк (LIKE). */
|
/** Лайк (LIKE). */
|
||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
|
/** Снятие лайка (UNLIKE). */
|
||||||
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|
||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
||||||
|
ensureSchemaUpgrades();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SqliteDbController getInstance() {
|
public static SqliteDbController getInstance() {
|
||||||
@ -67,4 +69,203 @@ public final class SqliteDbController {
|
|||||||
public void close() {
|
public void close() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureSchemaUpgrades() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
st.execute("PRAGMA foreign_keys = OFF");
|
||||||
|
|
||||||
|
ensureReactionsStateTable(st);
|
||||||
|
|
||||||
|
if (!tableExists(c, "connections_state")) {
|
||||||
|
createConnectionsStateTable(st);
|
||||||
|
} else if (needsConnectionsStateUpgrade(c)) {
|
||||||
|
rebuildConnectionsStateTable(st);
|
||||||
|
}
|
||||||
|
ensureChannelNamesStateTable(st);
|
||||||
|
ensureConnectionsIndexes(st);
|
||||||
|
ensureReactionsIndexes(st);
|
||||||
|
ensureChannelNamesIndexes(st);
|
||||||
|
|
||||||
|
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||||
|
|
||||||
|
st.execute("PRAGMA foreign_keys = ON");
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB schema upgrade failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB schema upgrade failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureReactionsStateTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||||
|
from_login TEXT NOT NULL,
|
||||||
|
from_bch_name TEXT NOT NULL,
|
||||||
|
reaction_type INTEGER NOT NULL,
|
||||||
|
to_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
last_sub_type INTEGER NOT NULL,
|
||||||
|
UNIQUE (
|
||||||
|
from_login,
|
||||||
|
from_bch_name,
|
||||||
|
reaction_type,
|
||||||
|
to_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash
|
||||||
|
)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS connections_state (
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
rel_type INTEGER NOT NULL,
|
||||||
|
to_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||||
|
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||||
|
ON connections_state (login);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||||
|
ON connections_state (to_login);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||||
|
ON connections_state (login, to_login);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||||
|
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureReactionsIndexes(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||||
|
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||||
|
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
|
slug TEXT NOT NULL PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
owner_bch_name TEXT NOT NULL,
|
||||||
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
|
channel_root_block_hash BLOB NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
||||||
|
ON channel_names_state (slug);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||||
|
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||||
|
ON channel_names_state (owner_login, owner_bch_name);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE connections_state_v2 (
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
rel_type INTEGER NOT NULL,
|
||||||
|
to_login TEXT NOT NULL,
|
||||||
|
to_bch_name TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BLOB NOT NULL,
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||||
|
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
INSERT OR IGNORE INTO connections_state_v2
|
||||||
|
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
rel_type,
|
||||||
|
to_login,
|
||||||
|
to_bch_name,
|
||||||
|
COALESCE(to_block_number, 0),
|
||||||
|
COALESCE(to_block_hash, zeroblob(32))
|
||||||
|
FROM connections_state
|
||||||
|
WHERE login IS NOT NULL
|
||||||
|
AND to_login IS NOT NULL
|
||||||
|
AND to_bch_name IS NOT NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("DROP TABLE connections_state");
|
||||||
|
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean tableExists(Connection c, String tableName) throws SQLException {
|
||||||
|
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
|
||||||
|
try (var ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, tableName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
|
||||||
|
boolean toBlockNumberNotNull = false;
|
||||||
|
boolean toBlockHashNotNull = false;
|
||||||
|
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("name");
|
||||||
|
int notNull = rs.getInt("notnull");
|
||||||
|
if ("to_block_number".equalsIgnoreCase(name)) {
|
||||||
|
toBlockNumberNotNull = notNull == 1;
|
||||||
|
}
|
||||||
|
if ("to_block_hash".equalsIgnoreCase(name)) {
|
||||||
|
toBlockHashNotNull = notNull == 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !toBlockNumberNotNull || !toBlockHashNotNull;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package shine.db.channels;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public final class ChannelNameRules {
|
||||||
|
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
||||||
|
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||||
|
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||||
|
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
||||||
|
|
||||||
|
private ChannelNameRules() {}
|
||||||
|
|
||||||
|
public static String normalizeDisplayName(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String requireValidDisplayNameForCreate(String rawName) {
|
||||||
|
String normalized = normalizeDisplayName(rawName);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = normalized.codePointCount(0, normalized.length());
|
||||||
|
if (length < MIN_DISPLAY_NAME_LENGTH || length > MAX_DISPLAY_NAME_LENGTH) {
|
||||||
|
throw new IllegalArgumentException("channelName length must be 3..32");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DISPLAY_ALLOWED_PATTERN.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toCanonicalSlug(String rawName) {
|
||||||
|
String normalized = normalizeDisplayName(rawName);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
|
||||||
|
StringBuilder slug = new StringBuilder(lowered.length());
|
||||||
|
boolean pendingSeparator = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < lowered.length(); ) {
|
||||||
|
int cp = lowered.codePointAt(i);
|
||||||
|
i += Character.charCount(cp);
|
||||||
|
|
||||||
|
if (cp == ' ' || cp == '_' || cp == '-') {
|
||||||
|
pendingSeparator = slug.length() > 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLatinOrCyrillicOrDigit(cp)) {
|
||||||
|
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSeparator && slug.length() > 0) {
|
||||||
|
slug.append('-');
|
||||||
|
}
|
||||||
|
pendingSeparator = false;
|
||||||
|
slug.appendCodePoint(cp);
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = slug.length();
|
||||||
|
if (len > 0 && slug.charAt(len - 1) == '-') {
|
||||||
|
slug.deleteCharAt(len - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.length() == 0) {
|
||||||
|
throw new IllegalArgumentException("channelName canonical slug is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLatinOrCyrillicOrDigit(int cp) {
|
||||||
|
if (Character.isDigit(cp)) return true;
|
||||||
|
Character.UnicodeScript script = Character.UnicodeScript.of(cp);
|
||||||
|
return script == Character.UnicodeScript.LATIN || script == Character.UnicodeScript.CYRILLIC;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ChannelNameStateDAO {
|
||||||
|
private static volatile ChannelNameStateDAO instance;
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private ChannelNameStateDAO() {}
|
||||||
|
|
||||||
|
public static ChannelNameStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (ChannelNameStateDAO.class) {
|
||||||
|
if (instance == null) instance = new ChannelNameStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsBySlug(Connection c, String slug) throws SQLException {
|
||||||
|
String sql = "SELECT 1 FROM channel_names_state WHERE slug = ? LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, slug);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsBySlug(String slug) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return existsBySlug(c, slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearAll(Connection c) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("DELETE FROM channel_names_state")) {
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO channel_names_state (
|
||||||
|
slug,
|
||||||
|
display_name,
|
||||||
|
owner_login,
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getSlug());
|
||||||
|
ps.setString(2, entry.getDisplayName());
|
||||||
|
ps.setString(3, entry.getOwnerLogin());
|
||||||
|
ps.setString(4, entry.getOwnerBlockchainName());
|
||||||
|
ps.setInt(5, entry.getChannelRootBlockNumber());
|
||||||
|
ps.setBytes(6, entry.getChannelRootBlockHash());
|
||||||
|
ps.setLong(7, entry.getCreatedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertAll(Connection c, List<ChannelNameStateEntry> entries) throws SQLException {
|
||||||
|
for (ChannelNameStateEntry entry : entries) {
|
||||||
|
insert(c, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class ChannelNameStateEntry {
|
||||||
|
private String slug;
|
||||||
|
private String displayName;
|
||||||
|
private String ownerLogin;
|
||||||
|
private String ownerBlockchainName;
|
||||||
|
private int channelRootBlockNumber;
|
||||||
|
private byte[] channelRootBlockHash;
|
||||||
|
private long createdAtMs;
|
||||||
|
|
||||||
|
public String getSlug() {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSlug(String slug) {
|
||||||
|
this.slug = slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisplayName(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOwnerLogin() {
|
||||||
|
return ownerLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerLogin(String ownerLogin) {
|
||||||
|
this.ownerLogin = ownerLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOwnerBlockchainName() {
|
||||||
|
return ownerBlockchainName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerBlockchainName(String ownerBlockchainName) {
|
||||||
|
this.ownerBlockchainName = ownerBlockchainName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChannelRootBlockNumber() {
|
||||||
|
return channelRootBlockNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannelRootBlockNumber(int channelRootBlockNumber) {
|
||||||
|
this.channelRootBlockNumber = channelRootBlockNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getChannelRootBlockHash() {
|
||||||
|
return channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannelRootBlockHash(byte[] channelRootBlockHash) {
|
||||||
|
this.channelRootBlockHash = channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCreatedAtMs() {
|
||||||
|
return createdAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
|
this.createdAtMs = createdAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser
|
|||||||
// --- NEW: connections friends lists ---
|
// --- NEW: connections friends lists ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||||
@ -80,6 +81,10 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public final class JsonHandlerRegistry {
|
public final class JsonHandlerRegistry {
|
||||||
|
|
||||||
|
static {
|
||||||
|
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||||
|
}
|
||||||
|
|
||||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||||
Map.entry("AddUser", new Net_AddUser_Handler()),
|
Map.entry("AddUser", new Net_AddUser_Handler()),
|
||||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||||
|
|||||||
@ -19,19 +19,21 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_ut
|
|||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.channels.ChannelNameRules;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
import shine.db.dao.UserParamsDAO;
|
import shine.db.dao.UserParamsDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,8 +51,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
||||||
|
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||||
|
|
||||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO);
|
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||||
|
|
||||||
|
public Net_AddBlock_Handler() {
|
||||||
|
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
@ -114,9 +121,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String humanMessage(String code) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока";
|
if (code == null) return "Ошибка добавления блока"; return switch (code) {
|
||||||
|
|
||||||
return switch (code) {
|
|
||||||
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
||||||
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
||||||
case "db_error" -> "Ошибка базы данных";
|
case "db_error" -> "Ошибка базы данных";
|
||||||
@ -127,6 +132,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||||
case "bad_block_format" -> "Некорректный формат блока";
|
case "bad_block_format" -> "Некорректный формат блока";
|
||||||
case "bad_block_body" -> "Некорректное тело блока";
|
case "bad_block_body" -> "Некорректное тело блока";
|
||||||
|
case "bad_channel_name" -> "Некорректное название канала";
|
||||||
case "bad_block_number" -> "Некорректный номер блока";
|
case "bad_block_number" -> "Некорректный номер блока";
|
||||||
case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке";
|
case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке";
|
||||||
case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)";
|
case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)";
|
||||||
@ -136,7 +142,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
||||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||||
case "channel_name_already_exists" -> "Канал с таким именем уже существует";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
@ -237,9 +243,19 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChannelNameStateEntry channelNameStateEntry = null;
|
||||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||||
|
final String normalizedName;
|
||||||
|
final String slug;
|
||||||
try {
|
try {
|
||||||
if (channelNameExists(blockchainName, createChannelBody.channelName)) {
|
normalizedName = ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName);
|
||||||
|
slug = ChannelNameRules.toCanonicalSlug(normalizedName);
|
||||||
|
} catch (IllegalArgumentException badName) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (channelNameStateDAO.existsBySlug(slug)) {
|
||||||
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -247,6 +263,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
blockchainName, createChannelBody.channelName, e);
|
blockchainName, createChannelBody.channelName, e);
|
||||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
channelNameStateEntry = new ChannelNameStateEntry();
|
||||||
|
channelNameStateEntry.setSlug(slug);
|
||||||
|
channelNameStateEntry.setDisplayName(normalizedName);
|
||||||
|
channelNameStateEntry.setOwnerLogin(login);
|
||||||
|
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
|
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
|
||||||
|
channelNameStateEntry.setChannelRootBlockHash(block.getHash32());
|
||||||
|
channelNameStateEntry.setCreatedAtMs(block.timestamp * 1000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4.2) запрет дырок: blockNumber строго last+1
|
// 4.2) запрет дырок: blockNumber строго last+1
|
||||||
@ -390,9 +415,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam);
|
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (isChannelSlugConflict(e)) {
|
||||||
|
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
|
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
|
||||||
login, blockchainName, block.blockNumber, e);
|
login, blockchainName, block.blockNumber, e);
|
||||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
@ -415,28 +442,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return Base64Ws.decode(b64);
|
return Base64Ws.decode(b64);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean channelNameExists(String blockchainName, String channelName) throws Exception {
|
private static boolean isChannelSlugConflict(Throwable throwable) {
|
||||||
String sql = """
|
Throwable cur = throwable;
|
||||||
SELECT block_bytes
|
while (cur != null) {
|
||||||
FROM blocks
|
String message = String.valueOf(cur.getMessage());
|
||||||
WHERE bch_name = ? AND msg_type = 0 AND msg_sub_type = 1
|
if (message.contains("channel_names_state.slug")
|
||||||
""";
|
|| message.contains("uq_channel_names_state_slug")) {
|
||||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
|
return true;
|
||||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, blockchainName);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
while (rs.next()) {
|
|
||||||
byte[] bytes = rs.getBytes("block_bytes");
|
|
||||||
try {
|
|
||||||
BchBlockEntry entry = new BchBlockEntry(bytes);
|
|
||||||
if (entry.body instanceof CreateChannelBody ccb) {
|
|
||||||
if (ccb.channelName.equalsIgnoreCase(channelName)) return true;
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// ignore bad historic rows, uniqueness check is best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
cur = cur.getCause();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,11 @@ package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_u
|
|||||||
import blockchain.BchBlockEntry;
|
import blockchain.BchBlockEntry;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
import shine.db.dao.UserParamsDAO;
|
import shine.db.dao.UserParamsDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
@ -23,20 +25,26 @@ public final class BlockchainWriter {
|
|||||||
|
|
||||||
private final BlocksDAO blocksDAO;
|
private final BlocksDAO blocksDAO;
|
||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
private final UserParamsDAO userParamsDAO;
|
private final UserParamsDAO userParamsDAO;
|
||||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||||
|
|
||||||
public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, UserParamsDAO userParamsDAO) {
|
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||||
|
BlockchainStateDAO stateDAO,
|
||||||
|
UserParamsDAO userParamsDAO,
|
||||||
|
ChannelNameStateDAO channelNameStateDAO) {
|
||||||
this.blocksDAO = blocksDAO;
|
this.blocksDAO = blocksDAO;
|
||||||
this.stateDAO = stateDAO;
|
this.stateDAO = stateDAO;
|
||||||
this.userParamsDAO = userParamsDAO;
|
this.userParamsDAO = userParamsDAO;
|
||||||
|
this.channelNameStateDAO = channelNameStateDAO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendBlockAndState(String blockchainName,
|
public void appendBlockAndState(String blockchainName,
|
||||||
BchBlockEntry block,
|
BchBlockEntry block,
|
||||||
BlockchainStateEntry st,
|
BlockchainStateEntry st,
|
||||||
BlockEntry be,
|
BlockEntry be,
|
||||||
UserParamEntry userParamEntry) throws SQLException {
|
UserParamEntry userParamEntry,
|
||||||
|
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
|
|
||||||
@ -59,6 +67,10 @@ public final class BlockchainWriter {
|
|||||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (channelNameStateEntry != null) {
|
||||||
|
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||||
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
|||||||
@ -0,0 +1,141 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.channels.ChannelNameRules;
|
||||||
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class ChannelNamesStateBootstrapper {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||||
|
private static final int MSG_TYPE_TECH = 0;
|
||||||
|
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
||||||
|
private static volatile boolean bootstrapped;
|
||||||
|
|
||||||
|
private ChannelNamesStateBootstrapper() {}
|
||||||
|
|
||||||
|
public static void bootstrapOrFailFast() {
|
||||||
|
if (bootstrapped) return;
|
||||||
|
synchronized (ChannelNamesStateBootstrapper.class) {
|
||||||
|
if (bootstrapped) return;
|
||||||
|
rebuildFromBlocksOrThrow();
|
||||||
|
bootstrapped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rebuildFromBlocksOrThrow() {
|
||||||
|
ChannelNameStateDAO dao = ChannelNameStateDAO.getInstance();
|
||||||
|
List<ChannelNameStateEntry> entries = new ArrayList<>();
|
||||||
|
Map<String, String> slugToIdentity = new LinkedHashMap<>();
|
||||||
|
List<String> conflicts = new ArrayList<>();
|
||||||
|
List<String> skipped = new ArrayList<>();
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT login, bch_name, block_number, block_hash, block_bytes
|
||||||
|
FROM blocks
|
||||||
|
WHERE msg_type = ? AND msg_sub_type = ?
|
||||||
|
ORDER BY bch_name, block_number
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, MSG_TYPE_TECH);
|
||||||
|
ps.setInt(2, MSG_SUB_TYPE_CREATE_CHANNEL);
|
||||||
|
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String ownerLogin = rs.getString("login");
|
||||||
|
String ownerBch = rs.getString("bch_name");
|
||||||
|
int blockNumber = rs.getInt("block_number");
|
||||||
|
byte[] blockHash = rs.getBytes("block_hash");
|
||||||
|
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||||
|
|
||||||
|
final BchBlockEntry parsed;
|
||||||
|
final CreateChannelBody createChannelBody;
|
||||||
|
try {
|
||||||
|
parsed = new BchBlockEntry(blockBytes);
|
||||||
|
if (!(parsed.body instanceof CreateChannelBody ccb)) continue;
|
||||||
|
createChannelBody = ccb;
|
||||||
|
} catch (Exception parseError) {
|
||||||
|
skipped.add(ownerBch + "#" + blockNumber + " (parse_error)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
final String slug;
|
||||||
|
try {
|
||||||
|
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||||
|
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||||
|
} catch (Exception badName) {
|
||||||
|
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String identity = ownerBch + "#" + blockNumber;
|
||||||
|
String existing = slugToIdentity.putIfAbsent(slug, identity);
|
||||||
|
if (existing != null && !existing.equals(identity)) {
|
||||||
|
conflicts.add("slug=\"" + slug + "\" conflicts: " + existing + " vs " + identity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelNameStateEntry entry = new ChannelNameStateEntry();
|
||||||
|
entry.setSlug(slug);
|
||||||
|
entry.setDisplayName(displayName);
|
||||||
|
entry.setOwnerLogin(ownerLogin);
|
||||||
|
entry.setOwnerBlockchainName(ownerBch);
|
||||||
|
entry.setChannelRootBlockNumber(blockNumber);
|
||||||
|
entry.setChannelRootBlockHash(blockHash);
|
||||||
|
entry.setCreatedAtMs(parsed.timestamp * 1000L);
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.clearAll(c);
|
||||||
|
dao.insertAll(c, entries);
|
||||||
|
c.commit();
|
||||||
|
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||||
|
if (!conflicts.isEmpty()) {
|
||||||
|
log.warn("channel_names_state bootstrap detected {} slug conflicts (kept first occurrence)", conflicts.size());
|
||||||
|
int preview = Math.min(conflicts.size(), 10);
|
||||||
|
for (int i = 0; i < preview; i++) {
|
||||||
|
log.warn("channel_names_state conflict: {}", conflicts.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skipped.isEmpty()) {
|
||||||
|
log.warn("channel_names_state bootstrap skipped {} legacy entries", skipped.size());
|
||||||
|
int preview = Math.min(skipped.size(), 10);
|
||||||
|
for (int i = 0; i < preview; i++) {
|
||||||
|
log.warn("channel_names_state skipped: {}", skipped.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
c.rollback();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
|||||||
import blockchain.body.BodyRecord;
|
import blockchain.body.BodyRecord;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.TextBody;
|
import blockchain.body.TextBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
import shine.db.MsgSubType;
|
import shine.db.MsgSubType;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@ -15,6 +17,7 @@ import java.util.List;
|
|||||||
|
|
||||||
final class ChannelsReadSupport {
|
final class ChannelsReadSupport {
|
||||||
static final int MSG_TYPE_TEXT = 1;
|
static final int MSG_TYPE_TEXT = 1;
|
||||||
|
static final int MSG_TYPE_REACTION = 2;
|
||||||
static final int MSG_TYPE_TECH = 0;
|
static final int MSG_TYPE_TECH = 0;
|
||||||
|
|
||||||
private ChannelsReadSupport() {}
|
private ChannelsReadSupport() {}
|
||||||
@ -122,7 +125,11 @@ final class ChannelsReadSupport {
|
|||||||
BchBlockEntry e = new BchBlockEntry(blockBytes);
|
BchBlockEntry e = new BchBlockEntry(blockBytes);
|
||||||
TextInfo ti = new TextInfo();
|
TextInfo ti = new TextInfo();
|
||||||
ti.createdAtMs = e.timestamp * 1000L;
|
ti.createdAtMs = e.timestamp * 1000L;
|
||||||
if (e.body instanceof TextBody tb) {
|
if (e.body instanceof TextLineBody tlb) {
|
||||||
|
ti.text = tlb.message;
|
||||||
|
} else if (e.body instanceof TextReplyBody trb) {
|
||||||
|
ti.text = trb.message;
|
||||||
|
} else if (e.body instanceof TextBody tb) {
|
||||||
ti.text = tb.message;
|
ti.text = tb.message;
|
||||||
}
|
}
|
||||||
return ti;
|
return ti;
|
||||||
@ -205,6 +212,34 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean isLikedByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
|
||||||
|
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String sql = """
|
||||||
|
SELECT msg_sub_type
|
||||||
|
FROM blocks
|
||||||
|
WHERE login = ? COLLATE NOCASE
|
||||||
|
AND msg_type = ?
|
||||||
|
AND to_bch_name = ?
|
||||||
|
AND to_block_number = ?
|
||||||
|
AND to_block_hash = ?
|
||||||
|
ORDER BY block_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setInt(2, MSG_TYPE_REACTION);
|
||||||
|
ps.setString(3, toBch);
|
||||||
|
ps.setInt(4, toBlockNumber);
|
||||||
|
ps.setBytes(5, toBlockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return false;
|
||||||
|
return rs.getInt("msg_sub_type") == MsgSubType.REACTION_LIKE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static byte[] hexToBytes(String s) {
|
static byte[] hexToBytes(String s) {
|
||||||
if (s == null) return null;
|
if (s == null) return null;
|
||||||
String x = s.trim();
|
String x = s.trim();
|
||||||
|
|||||||
@ -38,6 +38,10 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||||
|
|
||||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||||
|
String viewerLogin = ctx != null ? ctx.getLogin() : null;
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||||
|
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
|
||||||
|
}
|
||||||
String ownerBch = req.getChannel().getOwnerBlockchainName();
|
String ownerBch = req.getChannel().getOwnerBlockchainName();
|
||||||
int lineCode = req.getChannel().getChannelRootBlockNumber();
|
int lineCode = req.getChannel().getChannelRootBlockNumber();
|
||||||
|
|
||||||
@ -102,6 +106,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
|
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
|
||||||
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) {
|
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||||
}
|
}
|
||||||
|
|
||||||
int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp());
|
int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp());
|
||||||
@ -35,9 +35,13 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
|
int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
|
||||||
|
|
||||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||||
|
String viewerLogin = ctx != null ? ctx.getLogin() : null;
|
||||||
|
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||||
|
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
|
||||||
|
}
|
||||||
PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
|
PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
|
||||||
if (focusRow == null) {
|
if (focusRow == null) {
|
||||||
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
|
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
|
Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
|
||||||
@ -45,7 +49,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
|
||||||
resp.setFocus(toNode(c, focusRow));
|
resp.setFocus(toNode(c, focusRow, viewerLogin));
|
||||||
|
|
||||||
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
||||||
PostRow cur = focusRow;
|
PostRow cur = focusRow;
|
||||||
@ -53,27 +57,27 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
if (cur.toBlockNumber == null || cur.toBchName == null) break;
|
if (cur.toBlockNumber == null || cur.toBchName == null) break;
|
||||||
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
||||||
if (parent == null) break;
|
if (parent == null) break;
|
||||||
ancestors.add(0, toNode(c, parent));
|
ancestors.add(0, toNode(c, parent, viewerLogin));
|
||||||
cur = parent;
|
cur = parent;
|
||||||
}
|
}
|
||||||
resp.setAncestors(ancestors);
|
resp.setAncestors(ancestors);
|
||||||
|
|
||||||
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit));
|
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit, viewerLogin));
|
||||||
return resp;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("GetMessageThread failed", e);
|
log.error("GetMessageThread failed", e);
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit) throws Exception {
|
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
||||||
if (depthDown <= 0) return List.of();
|
if (depthDown <= 0) return List.of();
|
||||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||||
for (PostRow row : replies) {
|
for (PostRow row : replies) {
|
||||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||||
t.setNode(toNode(c, row));
|
t.setNode(toNode(c, row, viewerLogin));
|
||||||
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit));
|
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit, viewerLogin));
|
||||||
out.add(t);
|
out.add(t);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -133,7 +137,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row) throws Exception {
|
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row, String viewerLogin) throws Exception {
|
||||||
Net_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
|
Net_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
|
||||||
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
ref.setBlockNumber(row.blockNumber);
|
ref.setBlockNumber(row.blockNumber);
|
||||||
@ -173,6 +177,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
|
|
||||||
if (row.lineCode != null && row.lineCode >= 0) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
@ -222,3 +227,4 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int msgSubType;
|
int msgSubType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,14 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_GetChannelMessages_Request extends Net_Request {
|
public class Net_GetChannelMessages_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
private ChannelSelector channel;
|
private ChannelSelector channel;
|
||||||
private Integer limit;
|
private Integer limit;
|
||||||
private String sort;
|
private String sort;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
public ChannelSelector getChannel() { return channel; }
|
public ChannelSelector getChannel() { return channel; }
|
||||||
public void setChannel(ChannelSelector channel) { this.channel = channel; }
|
public void setChannel(ChannelSelector channel) { this.channel = channel; }
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String text;
|
private String text;
|
||||||
private int likesCount;
|
private int likesCount;
|
||||||
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
@ -63,6 +64,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getLikesCount() { return likesCount; }
|
public int getLikesCount() { return likesCount; }
|
||||||
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
||||||
|
|
||||||
|
public boolean isLikedByMe() { return likedByMe; }
|
||||||
|
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
||||||
|
|
||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,15 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_GetMessageThread_Request extends Net_Request {
|
public class Net_GetMessageThread_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
private MessageSelector message;
|
private MessageSelector message;
|
||||||
private Integer depthUp;
|
private Integer depthUp;
|
||||||
private Integer depthDown;
|
private Integer depthDown;
|
||||||
private Integer limitChildrenPerNode;
|
private Integer limitChildrenPerNode;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
public MessageSelector getMessage() { return message; }
|
public MessageSelector getMessage() { return message; }
|
||||||
public void setMessage(MessageSelector message) { this.message = message; }
|
public void setMessage(MessageSelector message) { this.message = message; }
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseF
|
|||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.MsgSubType;
|
import shine.db.MsgSubType;
|
||||||
import shine.db.dao.ConnectionsStateDAO;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@ -42,15 +41,10 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
|
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionsStateDAO.getInstance().upsertRelation(
|
// Idempotent insert for close-friend relation.
|
||||||
c,
|
// Using INSERT OR IGNORE avoids ON CONFLICT(column list) mismatches
|
||||||
from,
|
// across DB instances with different UNIQUE schemas.
|
||||||
MsgSubType.CONNECTION_FRIEND,
|
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch);
|
||||||
canonicalTo,
|
|
||||||
targetBch,
|
|
||||||
0,
|
|
||||||
new byte[32]
|
|
||||||
);
|
|
||||||
|
|
||||||
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
@ -82,4 +76,26 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void insertCloseFriendIgnoreDuplicate(Connection c,
|
||||||
|
String login,
|
||||||
|
String toLogin,
|
||||||
|
String toBchName) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
INSERT OR IGNORE INTO connections_state (
|
||||||
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setInt(2, MsgSubType.CONNECTION_FRIEND);
|
||||||
|
ps.setString(3, toLogin);
|
||||||
|
ps.setString(4, toBchName);
|
||||||
|
ps.setInt(5, 0);
|
||||||
|
ps.setBytes(6, new byte[32]);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,17 @@ public final class AuthKeyUtils {
|
|||||||
public static byte[] parseEd25519PublicKey(String key, String fieldName) {
|
public static byte[] parseEd25519PublicKey(String key, String fieldName) {
|
||||||
String normalized = normalize(key, fieldName);
|
String normalized = normalize(key, fieldName);
|
||||||
|
|
||||||
|
// Legacy format is plain BASE64(32 bytes) and may contain '/' characters.
|
||||||
|
// Try legacy decode first to avoid misinterpreting base64 payload as algorithm prefix.
|
||||||
|
try {
|
||||||
|
return Base64Ws.decodeLen(normalized, 32, fieldName);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// continue with explicit algorithm/key format
|
||||||
|
}
|
||||||
|
|
||||||
int slash = normalized.indexOf('/');
|
int slash = normalized.indexOf('/');
|
||||||
if (slash < 0) {
|
if (slash < 0) {
|
||||||
return Base64Ws.decodeLen(normalized, 32, fieldName);
|
throw new IllegalArgumentException(fieldName + " has bad base64/key format");
|
||||||
}
|
}
|
||||||
|
|
||||||
String algorithm = normalized.substring(0, slash).trim();
|
String algorithm = normalized.substring(0, slash).trim();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user