Compare commits

..

No commits in common. "97d37a2eb653dbf10f7fd7b03cbc5ae9bdcc7b7d1d97ccca8305d68dfc9d0a9c" and "c44d755ce062d897d8bfc4338a540d0a86ab6f5629c4121c0e5fb138a769ac14" have entirely different histories.

53 changed files with 992 additions and 1905 deletions

View File

@ -14,29 +14,3 @@
- `client.version` — версия клиентского UI. - `client.version` — версия клиентского UI.
- `server.version` — версия серверной части. - `server.version` — версия серверной части.
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное. - Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
## Deploy
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
- На проде специальный файл для звонков:
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
- Общий серверный лог (и ротации) на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
- `/home/player/SHiNE/shine-server/logs/app.YYYY-MM-DD.log`
- Для анализа причин недозвона в первую очередь фильтровать записи по ключам:
- `CallDeliveryReport`
- `call_connected`
- `outgoing_failed`
- `incoming_failed`
- `call_busy`
- `call_declined`
- `unknown_error`
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.

View File

@ -1,53 +0,0 @@
# SHiNE Deployment Servers Inventory
## Scope
This folder contains all deployment-related notes and server records for SHiNE.
## Legacy Production Server
- Name: `VPS-02` (legacy)
- Access: `root@194.87.0.247`
- Current role: old production server
- Confirmed services:
- `coturn` is installed and active (`systemd: active/running`)
- `caddy` is installed (reported by project context; verify version on host if needed)
- TURN configuration observed on host:
- `listening-port=3478`
- `external-ip=194.87.0.247`
- `relay-ip=194.87.0.247`
- auth mode: `use-auth-secret` + `static-auth-secret`
- SHiNE deployment note:
- This host is used as current/legacy runtime for SHiNE.
- Gradle-based deployment is used in this project (see repository deploy tasks and scripts).
## Target Production Server (Migration)
- Name: `VPS-05` (new)
- Access: `root@45.136.124.227`
- Planned role: new primary production server for gradual migration
- Baseline setup done:
- `ripgrep` installed
- user `player` created
- user `player` added to `sudo` group
- deployment directory created: `/home/player/SHiNE`
- Rule:
- All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`.
## Additional TURN Node
- Name: `promo-node-93`
- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations)
- Role: additional TURN node for SHiNE calls
- TURN setup:
- `coturn` installed and active
- `listening-port=3478`
- `tls-listening-port=5349`
- `use-auth-secret` + shared `static-auth-secret`
- relay UDP port range: `49152-50152`
- Runtime files:
- `/etc/turnserver.conf`
- `/home/player/SHiNE/coturn/turnserver.conf`
- Cleanup done:
- Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`.
## Next Migration Steps (recommended)
1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required).
2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow.
3. Move traffic gradually and validate logs/metrics before final cutover.

View File

@ -31,11 +31,3 @@
- количество успешных вставок пар, - количество успешных вставок пар,
- доля доставок в WS/push, - доля доставок в WS/push,
- количество ретраев межсерверной пересылки. - количество ретраев межсерверной пересылки.
## 6) Ограничение текущих звонков (важно)
- Сейчас звонки работают только в рамках одного сигнального сервера (или единого контура, где обе стороны уже подключены).
- Сценарий «пользователь A на своих серверах, пользователь B на других серверах» пока не поддержан.
- TODO на будущее:
- временная межсерверная авторизация/сессия для старта звонка,
- отправка сигнальных сообщений между разными серверами пользователей,
- аккуратное завершение временной сессии после установления/завершения звонка.

View File

@ -1,41 +0,0 @@
# TODO: Звонки и межсерверность
## Текущее ограничение
- Текущая реализация звонков фактически работает в одном сигнальном контуре (один сервер/единый кластер, где обе стороны уже присутствуют).
- Если пользователь A подключён к серверу A, а пользователь B к серверу B (и между ними нет общего сигнального слоя), `CallInviteBroadcast`/`CallSignalToSession` не смогут полноценно провести звонок между ними.
## Почему так сейчас
- Сигналинг звонка привязан к активным сессиям и событиям на конкретном сервере.
- Выбор целевой сессии (`sessionId`) и обмен `OFFER/ANSWER/ICE` происходит в рамках текущего сигнального контура.
- Push решает только «разбудить/уведомить», но не заменяет межсерверный сигнальный канал.
## Что можно сделать дальше
- Добавить временное межсерверное подключение именно для старта и ведения звонка:
- инициатор получает short-lived access на сервер callee (или через доверенный межсерверный gateway),
- в рамках короткой сессии отправляет invite/signal для конкретного `callId`,
- после завершения звонка временная сессия закрывается автоматически.
## Что нужно доработать для этого
1. Межсерверная доверенная модель:
- подпись/верификация межсерверных вызовов,
- allowlist доверенных серверов и ротация ключей.
2. Короткоживущая «call-only» авторизация:
- отдельный тип токена/сессии с TTL (например 13 минуты),
- минимальные права только на `CallInviteBroadcast/CallSignalToSession`.
3. Маршрутизация сессий пользователя между серверами:
- где находится активная сессия callee,
- как доставлять `stop_call` и terminal-сигналы на все устройства callee.
4. Идемпотентность и дедупликация:
- защита от повторов межсерверных сигналов по `callId + eventId`,
- корректная обработка out-of-order событий.
5. Наблюдаемость:
- метрики межсерверной доставки сигналов,
- диагностика по стадиям звонка и причинам срыва.
## Временный рабочий подход (до межсерверности)
- Держать звонки в одном сигнальном контуре.
- Использовать WebPush как fallback-уведомление (`incoming_call`/`stop_call`) для офлайн-сессий.

View File

@ -1,2 +1,2 @@
client.version=1.2.44 client.version=1.2.35
server.version=1.2.38 server.version=1.2.29

View File

@ -1,105 +0,0 @@
Дата: 27.04.2026
# Ответ по блокчейну, форматам, каналам и расширяемости
Короткий итог: текущую систему можно запускать и развивать дальше. Архитектура уже в целом готова к расширению версиями, но расширять нужно по строгим правилам совместимости.
## 1) Что важно зафиксировать про эволюцию форматов
Да, ваш подход верный:
- новые возможности добавляем через новые `type/subType/version` и/или новый `frameCode`;
- старые блоки не переписываем и не «переподписываем»;
- делаем конвертер/адаптер чтения: старые блоки читаются и приводятся к новой внутренней модели.
Это и есть правильная схема «старое автоматически читается и представляется в новом формате».
## 2) Есть ли в коде узкие места, где нельзя расширять
Критичных тупиков не видно, но есть важные ограничения:
- Парсер сейчас строгий: неизвестные `type/subType/version` и неизвестный frame отклоняются.
- Значит новые форматы нужно явно добавлять в парсер и серверную валидацию.
- Старые блоки без нужных полей не проблема, если новый код умеет читать их как legacy-ветку.
Вывод: расширение возможно, но только через явную поддержку версий, а не «само появится».
## 3) Про канал `0`
Зафиксировано правило:
- канал `0` оставляем как технический root;
- контент-посты туда пока не публикуем.
Это теперь отмечено в коде комментариями и проверками:
- на клиенте (UI) пост в `lineCode=0` блокируется с понятной ошибкой;
- на сервере добавлена валидация, которая тоже отклоняет `TEXT_POST` в канал `0`.
## 4) Формат аватара (`ava`) — обновлено
Раньше:
- `AR:<txId>`
Теперь поддерживается составной формат:
- `SHA256:<hash>,AR:<txId>`
Что сделано:
- парсер и сборщик формата в UI обновлены;
- при загрузке нового аватара считается `SHA-256` оптимизированного файла и сохраняется вместе с `AR`;
- при выборе существующего `txId` сначала качается файл, считается `SHA-256`, и только потом пишется `ava`;
- серверный граф связей теперь умеет извлекать `AR` из составной строки (включая fallback для legacy/кривых значений).
Это улучшает целостность: у вас есть не только адрес файла, но и контрольный хэш.
## 5) Как устроены ответы в каналах и насколько это надёжно
Ответы (`REPLY` / `EDIT_REPLY`) сделаны отдельно от линейных постов:
- `REPLY` хранит ссылку на цель (`toBlockchainName + blockNumber + hash32`) и текст;
- `EDIT_REPLY` хранит ссылку на оригинальный reply (`blockNumber + hash32`) и новый текст.
Сильные стороны:
- ссылка идёт по номеру + хэшу (устойчиво к подмене цели);
- редактирование отделено от оригинала и может агрегироваться в чтении.
Для будущего это расширяемо:
- можно добавить `TEXT_REPLY_V2` с новым payload, сохранив `V1` для старых клиентов.
## 6) Вложенные файлы и «мини-разметка» внутри сообщения
Идея рабочая, но лучше делать аккуратно.
### Вариант A: «теги в тексте» (ваша идея)
Плюсы:
- быстро внедрить;
- стандартно для пользователей (похоже на HTML/Markdown).
Риски:
- экранирование `<` `>` и безопасность рендера;
- сложнее валидировать и безопасно отображать (XSS/инъекции).
Если идти этим путём, лучше:
- разрешить только whitelist-теги (`img/file/h1/h2/h3/b/i` и т.д.);
- хранить метаданные файла явно: `type,size,sha256,ar`;
- рендерить через безопасный парсер, а не через «сырой HTML».
### Вариант B: структурированный payload (рекомендуется как основной)
Сделать `TEXT_POST_V2`:
- массив сегментов: `text`, `image`, `file`, `heading`, `style`;
- для файла хранить `kind`, `mime`, `size`, `sha256`, `storage`, `address`.
Плюс:
- надёжная валидация и безопасный рендер.
Оптимальная стратегия:
1. В UI можно дать «мини-разметку» для ввода.
2. На запись преобразовывать её в структурированный payload V2.
3. Для старых клиентов отдавать fallback plain text.
## 7) Практический roadmap без остановки разработки
1. Оставить текущий blockchain running.
2. Формально описать versioning policy (что считается breaking/non-breaking).
3. Зафиксировать channel `0` как технический root-only.
4. Использовать `ava = SHA256 + AR` как новый стандарт.
5. Запланировать `TEXT_*_V2` для вложений и форматирования.
Итог: стартовать и развивать можно уже сейчас. Основа хорошая, если строго держать версионирование и адаптеры чтения для legacy-блоков.

View File

@ -1,24 +0,0 @@
# SHiNE Predeploy Servers
## Access policy
- VPS-05 (`45.136.124.227`): use `player@45.136.124.227` for regular deployment operations.
- TURN shared secret (both TURN nodes): `def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106`
## Servers
1. `VPS-02` (legacy): `root@194.87.0.247`
- Legacy production host.
- caddy installed.
- coturn installed.
2. `VPS-05` (target): `root@45.136.124.227`
- New migration target.
- User `player` created, in `sudo`.
- SHiNE base dir: `/home/player/SHiNE`.
- TURN config path: `/etc/turnserver.conf` (coturn), work docs/scripts in `/home/player/SHiNE/coturn`.
3. `promo-node-93` (TURN node): `ubuntu@93.170.12.154`
- TURN installed: `coturn` active.
- TURN ports: `3478/tcp`, `3478/udp`, `5349/tcp`, `5349/udp`.
- TURN relay ports: `49152-50152/udp`.
- TURN config path: `/etc/turnserver.conf`.
- SHiNE dir for player: `/home/player/SHiNE/coturn`.
- Access user `player` created with sudo and SSH key copied from `ubuntu`.
- Legacy reverse SSH tunnel to `194.87.0.247:1200` was disabled (`reverse-ssh.service`).

View File

@ -1,17 +0,0 @@
# SHiNE TURN Server
This directory stores TURN setup scripts and operational instructions.
## Purpose
- Install and configure coturn for SHiNE calls.
- Keep repeatable setup scripts for new TURN nodes.
- Keep TURN-related config templates.
## Current production model
- Multiple TURN servers are supported by backend config section:
- `call.ice.turn.servers.1.*`
- `call.ice.turn.servers.2.*`
- ...
- Each server can use:
- REST auth (`sharedSecret`) for temporary credentials, or
- static `username`/`password` (fallback).

View File

@ -1,7 +1,5 @@
self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.__shineStoppedCalls = self.__shineStoppedCalls || new Map();
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
const data = event?.data || {}; const data = event?.data || {};
if (data.type === 'SKIP_WAITING') { if (data.type === 'SKIP_WAITING') {
@ -19,61 +17,6 @@ async function broadcastToClients(payload) {
}); });
} }
async function broadcastCallActionToClients(action, payload) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach((client) => {
client.postMessage({
type: 'SHINE_CALL_PUSH_ACTION',
action,
payload,
});
});
}
function rememberStoppedCall(callId, sentAtMs = 0) {
if (!callId) return;
const now = Date.now();
const markAtMs = Number.isFinite(Number(sentAtMs)) ? Number(sentAtMs) : now;
self.__shineStoppedCalls.set(callId, Math.max(now, markAtMs));
const cutoff = now - 10 * 60 * 1000;
for (const [id, ts] of self.__shineStoppedCalls.entries()) {
if (Number(ts || 0) < cutoff) self.__shineStoppedCalls.delete(id);
}
}
function isCallStopped(callId, sentAtMs = 0) {
if (!callId) return false;
const stoppedAt = Number(self.__shineStoppedCalls.get(callId) || 0);
if (!stoppedAt) return false;
const incomingAt = Number.isFinite(Number(sentAtMs)) ? Number(sentAtMs) : 0;
return incomingAt <= 0 || incomingAt <= stoppedAt;
}
async function closeCallNotification(callId) {
if (!callId) return;
const list = await self.registration.getNotifications({ tag: callId });
list.forEach((n) => {
try { n.close(); } catch {}
});
}
function decodePushJson(rawText) {
try {
if (!rawText) return {};
return JSON.parse(rawText);
} catch {
return {};
}
}
function encodeCallPushPayloadForUrl(payload) {
try {
return encodeURIComponent(JSON.stringify(payload || {}));
} catch {
return '';
}
}
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
let body = ''; let body = '';
let rawText = ''; let rawText = '';
@ -84,12 +27,13 @@ self.addEventListener('push', (event) => {
if (event.data) { if (event.data) {
const text = event.data.text(); const text = event.data.text();
rawText = text || ''; rawText = text || '';
const json = decodePushJson(rawText); try {
kind = String(json.kind || ''); const json = JSON.parse(rawText || '{}');
title = String(json.title || ''); kind = String(json.kind || '');
body = String(json.text || ''); title = String(json.title || '');
fromLogin = String(json.fromLogin || ''); body = String(json.text || '');
if (!kind && rawText) { fromLogin = String(json.fromLogin || '');
} catch {
body = rawText || ''; body = rawText || '';
} }
} }
@ -97,106 +41,34 @@ self.addEventListener('push', (event) => {
// ignore // ignore
} }
const json = decodePushJson(rawText); const shouldNotify = kind === 'new_message' || kind === 'test_push' || (!kind && body);
const callId = String(json.callId || '').trim();
const fromSessionId = String(json.fromSessionId || '').trim();
const toLogin = String(json.toLogin || '').trim();
const reason = String(json.reason || '').trim();
const sentAtMs = Number(json.sentAtMs || 0);
const expiresAtMs = Number(json.expiresAtMs || 0);
const nowMs = Date.now();
if (kind === 'stop_call' && callId) {
rememberStoppedCall(callId, sentAtMs || nowMs);
}
const isExpiredIncomingCall = kind === 'incoming_call'
&& Number.isFinite(expiresAtMs)
&& expiresAtMs > 0
&& nowMs > expiresAtMs;
const isIncomingCallAlreadyStopped = kind === 'incoming_call' && callId && isCallStopped(callId, sentAtMs || nowMs);
const shouldNotify = (
kind === 'new_message'
|| kind === 'test_push'
|| (kind === 'incoming_call' && !isExpiredIncomingCall && !isIncomingCallAlreadyStopped)
|| (!kind && body)
);
const notificationTitle = kind === 'test_push' const notificationTitle = kind === 'test_push'
? (title || 'SHiNE: тестовый push') ? (title || 'SHiNE: тестовый push')
: (kind === 'incoming_call' : 'SHiNE: входящее сообщение';
? 'SHiNE: входящий звонок'
: 'SHiNE: входящее сообщение');
const notifyPromise = shouldNotify const notifyPromise = shouldNotify
? self.registration.showNotification(notificationTitle, { ? self.registration.showNotification(notificationTitle, {
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'), body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'), tag: kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message',
renotify: true, renotify: true,
requireInteraction: kind === 'incoming_call',
data: {
kind,
callId,
fromLogin,
fromSessionId,
toLogin,
sentAtMs,
expiresAtMs,
reason,
},
actions: kind === 'incoming_call'
? [
{ action: 'accept', title: 'Ответить' },
{ action: 'decline', title: 'Сбросить' },
]
: [],
}) })
: Promise.resolve(); : Promise.resolve();
const closeOnStopPromise = kind === 'stop_call' && callId
? closeCallNotification(callId)
: Promise.resolve();
event.waitUntil(Promise.all([ event.waitUntil(Promise.all([
notifyPromise, notifyPromise,
closeOnStopPromise,
broadcastToClients({ broadcastToClients({
kind, kind,
body, body,
fromLogin, fromLogin,
fromSessionId,
toLogin,
callId,
sentAtMs,
expiresAtMs,
reason,
stale: isExpiredIncomingCall || isIncomingCallAlreadyStopped,
rawText, rawText,
receivedAt: nowMs, receivedAt: Date.now(),
}), }),
])); ]));
}); });
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
event.notification?.close(); event.notification?.close();
const action = String(event?.action || '').trim().toLowerCase();
const data = event?.notification?.data || {};
const payload = {
kind: String(data.kind || '').trim(),
callId: String(data.callId || '').trim(),
fromLogin: String(data.fromLogin || '').trim(),
fromSessionId: String(data.fromSessionId || '').trim(),
toLogin: String(data.toLogin || '').trim(),
sentAtMs: Number(data.sentAtMs || 0),
expiresAtMs: Number(data.expiresAtMs || 0),
reason: String(data.reason || '').trim(),
};
event.waitUntil((async () => { event.waitUntil((async () => {
if ((action === 'accept' || action === 'decline') && payload.callId) {
await broadcastCallActionToClients(action, payload);
}
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const existing = allClients.find((client) => { const existing = allClients.find((client) => {
try { try {
@ -206,26 +78,11 @@ self.addEventListener('notificationclick', (event) => {
} }
}); });
const openUrlBase = './index.html';
const encodedPayload = encodeCallPushPayloadForUrl(payload);
const openUrl = (action === 'accept' || action === 'decline')
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
: openUrlBase;
if (existing) { if (existing) {
try {
if (action === 'accept' || action === 'decline') {
existing.postMessage({
type: 'SHINE_CALL_PUSH_ACTION',
action,
payload,
});
}
} catch {}
await existing.focus(); await existing.focus();
return; return;
} }
await self.clients.openWindow(openUrl); await self.clients.openWindow('./index.html');
})()); })());
}); });

View File

@ -5,11 +5,8 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js';
import { import {
handleCallPushAction,
handleIncomingCallInvite, handleIncomingCallInvite,
handleIncomingCallPush,
handleIncomingCallSignal, handleIncomingCallSignal,
handleStopCallPush,
setCallDebugReporter, setCallDebugReporter,
startDebugConnectionAsInitiator, startDebugConnectionAsInitiator,
startDebugConnectionAsResponder, startDebugConnectionAsResponder,
@ -130,7 +127,6 @@ let uiUpdateReloadScheduled = false;
let pwaUpdateCheckAttempted = false; let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false; let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null; let uiVersionPeriodicIntervalId = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
setClientErrorTransport((payload) => authService.reportClientError(payload)); setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling(); initPwaInstallPromptHandling();
@ -224,85 +220,6 @@ function startConnectionCountdown() {
}, 1000); }, 1000);
} }
function savePendingCallPushAction(action, payload = {}) {
try {
const item = {
action: String(action || '').trim().toLowerCase(),
payload: payload || {},
savedAtMs: Date.now(),
};
localStorage.setItem(CALL_PUSH_PENDING_ACTION_KEY, JSON.stringify(item));
} catch {
// ignore localStorage errors
}
}
function loadPendingCallPushAction() {
try {
const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const action = String(parsed?.action || '').trim().toLowerCase();
if (action !== 'accept' && action !== 'decline') return null;
return {
action,
payload: parsed?.payload || {},
};
} catch {
return null;
}
}
function clearPendingCallPushAction() {
try {
localStorage.removeItem(CALL_PUSH_PENDING_ACTION_KEY);
} catch {
// ignore localStorage errors
}
}
function consumeCallPushActionFromUrlIfAny() {
try {
const params = new URLSearchParams(window.location.search || '');
const action = String(params.get('callPushAction') || '').trim().toLowerCase();
const rawPayload = String(params.get('callPushPayload') || '');
if (action !== 'accept' && action !== 'decline') return;
let payload = {};
if (rawPayload) {
try {
payload = JSON.parse(decodeURIComponent(rawPayload));
} catch {
payload = {};
}
}
savePendingCallPushAction(action, payload);
params.delete('callPushAction');
params.delete('callPushPayload');
const nextQuery = params.toString();
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
window.history.replaceState({}, '', nextUrl);
} catch {
// ignore URL parsing errors
}
}
async function processPendingCallPushActionIfPossible() {
if (!state.session.isAuthorized) return;
const pending = loadPendingCallPushAction();
if (!pending) return;
clearPendingCallPushAction();
try {
await handleCallPushAction(pending.action, pending.payload || {});
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'web-push',
message: 'Не удалось выполнить действие звонка из push',
details: { action: pending.action, error: error?.message || 'unknown' },
});
}
}
function setConnectionStatus(nextState, text = '') { function setConnectionStatus(nextState, text = '') {
const state = String(nextState || '').trim(); const state = String(nextState || '').trim();
if (!state) return; if (!state) return;
@ -760,13 +677,9 @@ async function ensureSessionRuntimeStarted() {
}); });
} }
}, 15_000); }, 15_000);
await processPendingCallPushActionIfPossible();
} }
async function init() { async function init() {
consumeCallPushActionFromUrlIfAny();
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'app', source: 'app',
@ -785,23 +698,12 @@ async function init() {
setSessionAuthorizedHandler(() => { setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted(); void ensureSessionRuntimeStarted();
void processPendingCallPushActionIfPossible();
}); });
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {}; const data = event?.data || {};
if (data.type === 'SHINE_CALL_PUSH_ACTION') {
const action = String(data.action || '').trim().toLowerCase();
const payload = data.payload || {};
if (action === 'accept' || action === 'decline') {
savePendingCallPushAction(action, payload);
void processPendingCallPushActionIfPossible();
}
return;
}
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return; if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {}; const payload = data.payload || {};
const kind = String(payload.kind || '').trim(); const kind = String(payload.kind || '').trim();
const now = Date.now(); const now = Date.now();
@ -821,11 +723,6 @@ async function init() {
message: 'Получено push-событие в service worker', message: 'Получено push-событие в service worker',
details: payload, details: payload,
}); });
if (kind === 'incoming_call' && !payload.stale && state.session.isAuthorized) {
void handleIncomingCallPush(payload);
} else if (kind === 'stop_call' && state.session.isAuthorized) {
void handleStopCallPush(payload);
}
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload })); window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
}); });
} }

View File

@ -1,5 +1,5 @@
import { state } from '../state.js'; import { state } from '../state.js';
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from '../services/arweave-file-service.js'; import { buildArweaveDataUrl, validateArweaveTxId } from '../services/arweave-file-service.js';
import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js'; import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js';
function normalizeLogin(value) { function normalizeLogin(value) {
@ -52,8 +52,6 @@ export function renderUserAvatar({
if (!validateArweaveTxId(txId)) { if (!validateArweaveTxId(txId)) {
return wrap; return wrap;
} }
const sha256Hex = String(avatar?.sha256Hex || avatar?.sha256 || '').trim().toLowerCase();
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
const img = document.createElement('img'); const img = document.createElement('img');
img.alt = 'Аватар'; img.alt = 'Аватар';
@ -67,7 +65,7 @@ export function renderUserAvatar({
setLoadedState(false); setLoadedState(false);
const gateway = state?.entrySettings?.arweaveServer; const gateway = state?.entrySettings?.arweaveServer;
void getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex }) void getCachedAvatarObjectUrl({ gateway, txId })
.then((objectUrl) => { .then((objectUrl) => {
const directUrl = buildArweaveDataUrl({ gateway, txId }); const directUrl = buildArweaveDataUrl({ gateway, txId });
let triedDirectUrl = false; let triedDirectUrl = false;
@ -85,12 +83,6 @@ export function renderUserAvatar({
}; };
img.onerror = () => { img.onerror = () => {
if (!triedDirectUrl) { if (!triedDirectUrl) {
if (expectedSha256Hex) {
releaseObjectUrl();
img.removeAttribute('src');
setLoadedState(false);
return;
}
triedDirectUrl = true; triedDirectUrl = true;
releaseObjectUrl(); releaseObjectUrl();
img.src = directUrl; img.src = directUrl;
@ -103,11 +95,6 @@ export function renderUserAvatar({
img.src = objectUrl; img.src = objectUrl;
}) })
.catch(() => { .catch(() => {
if (expectedSha256Hex) {
img.removeAttribute('src');
setLoadedState(false);
return;
}
let directUrl = ''; let directUrl = '';
try { try {
directUrl = buildArweaveDataUrl({ gateway, txId }); directUrl = buildArweaveDataUrl({ gateway, txId });

View File

@ -3,10 +3,8 @@ import {
buildArweaveDataUrl, buildArweaveDataUrl,
getArweaveUploadPrice, getArweaveUploadPrice,
prepareAvatarImageFile, prepareAvatarImageFile,
sha256HexFromArrayBuffer,
uploadArweaveFile, uploadArweaveFile,
validateArweaveTxId, validateArweaveTxId,
validateSha256Hex,
validateAvatarSourceFile, validateAvatarSourceFile,
} from '../services/arweave-file-service.js'; } from '../services/arweave-file-service.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
@ -71,7 +69,6 @@ export function openAvatarWizard({
let optimized = null; let optimized = null;
let priceInfo = null; let priceInfo = null;
let uploadedTxId = ''; let uploadedTxId = '';
let uploadedSha256Hex = '';
function revokePreviewUrl() { function revokePreviewUrl() {
if (!lastPreviewUrl) return; if (!lastPreviewUrl) return;
@ -161,7 +158,6 @@ export function openAvatarWizard({
const showStepExistingPreview = async (txId) => { const showStepExistingPreview = async (txId) => {
if (closed) return; if (closed) return;
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId }); const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
let existingSha256Hex = '';
root.innerHTML = ` root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true"> <div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card"> <div class="modal-card stack avatar-wizard-card">
@ -169,7 +165,7 @@ export function openAvatarWizard({
<div class="avatar-preview-circle avatar-wizard-preview"> <div class="avatar-preview-circle avatar-wizard-preview">
<img alt="Предпросмотр аватара" data-preview-image="true" /> <img alt="Предпросмотр аватара" data-preview-image="true" />
</div> </div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p> <p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p> <p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions"> <div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button> <button class="secondary-btn" type="button" data-action="back">Назад</button>
@ -190,19 +186,15 @@ export function openAvatarWizard({
try { try {
await ensurePreviewImage(previewUrl, imageEl); await ensurePreviewImage(previewUrl, imageEl);
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false; if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
} catch (error) { } catch (error) {
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.'); setNodeText(errorEl, 'Не удалось загрузить изображение по этому Transaction ID');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true; if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
} }
saveBtn?.addEventListener('click', async () => { saveBtn?.addEventListener('click', async () => {
try { try {
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex); await saveProfileAvatarArweave(cleanLogin, txId);
if (typeof onAvatarSaved === 'function') await onAvatarSaved(); if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve); close(true, resolve);
} catch { } catch {
@ -246,7 +238,7 @@ export function openAvatarWizard({
<img alt="Предпросмотр аватара" data-preview-image="true" /> <img alt="Предпросмотр аватара" data-preview-image="true" />
</div> </div>
<div class="avatar-wizard-meta" data-meta="true"></div> <div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p> <p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p> <p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions"> <div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button> <button class="secondary-btn" type="button" data-action="back">Назад</button>
@ -267,7 +259,6 @@ export function openAvatarWizard({
let selectedFile = null; let selectedFile = null;
optimized = null; optimized = null;
priceInfo = null; priceInfo = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => { modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve); if (event.target === modal) close(false, resolve);
@ -304,7 +295,6 @@ export function openAvatarWizard({
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div> <div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div> <div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div> <div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div> <div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
`; `;
@ -348,13 +338,9 @@ export function openAvatarWizard({
], ],
}); });
uploadedTxId = String(uploaded.id || '').trim(); uploadedTxId = String(uploaded.id || '').trim();
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
if (!uploadedTxId) { if (!uploadedTxId) {
throw new Error('Пустой Transaction ID'); throw new Error('Пустой Transaction ID');
} }
if (!validateSha256Hex(uploadedSha256Hex)) {
throw new Error('Некорректный SHA256');
}
showStepUploaded(); showStepUploaded();
} catch { } catch {
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.'); setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
@ -371,8 +357,6 @@ export function openAvatarWizard({
<h3 class="modal-title">Файл загружен в Arweave</h3> <h3 class="modal-title">Файл загружен в Arweave</h3>
<p class="meta-muted">Transaction ID:</p> <p class="meta-muted">Transaction ID:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p> <p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
<p class="avatar-wizard-error" data-error="true"></p> <p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions"> <div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button> <button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
@ -394,7 +378,7 @@ export function openAvatarWizard({
}); });
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => { root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
try { try {
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex); await saveProfileAvatarArweave(cleanLogin, uploadedTxId);
if (typeof onAvatarSaved === 'function') await onAvatarSaved(); if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve); close(true, resolve);
} catch { } catch {

View File

@ -44,11 +44,11 @@ export function render({ navigate }) {
form.className = 'card stack'; form.className = 'card stack';
form.innerHTML = ` form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong> <strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p> <p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p> <p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
<label for="channel-name">Название канала</label> <label for="channel-name">Название канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required /> <input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
<div id="channel-name-error" class="meta-muted inline-error"></div> <div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-description">Описание канала (необязательно)</label> <label for="channel-description">Описание канала (необязательно)</label>
@ -124,14 +124,18 @@ export function render({ navigate }) {
errorEl.textContent = ''; errorEl.textContent = '';
try { try {
await authService.addBlockCreateChannel({ const created = await authService.addBlockCreateChannel({
login, login,
storagePwd, storagePwd,
channelName: normalizeChannelDisplayName(check.name), channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description), channelDescription: normalizeChannelDescription(check.description),
}); });
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`); const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
const successMessage = created?.usedLegacyDescriptionFallback && created?.savedDescriptionViaUserParam
? `${baseMessage} Описание сохранено через блок параметра.`
: baseMessage;
persistCreateSuccessFlash(successMessage);
navigate('channels-list'); navigate('channels-list');
} catch (error) { } catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.'); errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');

View File

@ -98,24 +98,6 @@ function buildAbsoluteRouteUrl(routePath = '') {
function parseThreadSelector(route) { function parseThreadSelector(route) {
const params = route?.params || {}; const params = route?.params || {};
if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) {
return {
short: {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
},
message: {
blockchainName: '',
blockNumber: toSafeInt(params.messageBlockNumber),
blockHash: normalizeRouteHash(params.messageBlockHash),
},
channel: {
ownerBlockchainName: '',
rootBlockNumber: null,
rootBlockHash: '0',
},
};
}
const blockNumber = toSafeInt(params.messageBlockNumber); const blockNumber = toSafeInt(params.messageBlockNumber);
if (!params.messageBlockchainName || blockNumber == null) return null; if (!params.messageBlockchainName || blockNumber == null) return null;
@ -171,17 +153,7 @@ function buildBackRoute(selector) {
} }
function buildThreadRouteFromTarget(target, selector) { function buildThreadRouteFromTarget(target, selector) {
if (!target) return ''; if (!target || !selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
if (selector?.short?.ownerLogin && selector?.short?.channelName) {
return [
'channel',
encodeRoutePart(selector.short.ownerLogin),
encodeRoutePart(selector.short.channelName),
target.blockNumber,
normalizeRouteHash(target.blockHash),
].join('/');
}
if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
return [ return [
'channel-thread-view', 'channel-thread-view',
encodeRoutePart(target.blockchainName), encodeRoutePart(target.blockchainName),
@ -321,6 +293,13 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
card.append(meta, body, stats); card.append(meta, body, stats);
if (options.showViews === true) {
const views = document.createElement('p');
views.className = 'thread-node-views';
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
card.append(views);
}
const target = buildTargetFromNode(node); const target = buildTargetFromNode(node);
const refKey = messageRefKey(target); const refKey = messageRefKey(target);
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true; const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
@ -358,19 +337,15 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
likeButton.type = 'button'; likeButton.type = 'button';
likeButton.className = 'secondary-btn thread-like-btn'; likeButton.className = 'secondary-btn thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked'); if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк'); likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters(); revealCounters();
await longPressFeel(event.currentTarget, 130); await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true; likeButton.disabled = true;
likeButton.textContent = 'Лайк...'; likeButton.textContent = 'Сияние...';
try { try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) { } catch (error) {
@ -386,7 +361,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
const replyButton = document.createElement('button'); const replyButton = document.createElement('button');
replyButton.type = 'button'; replyButton.type = 'button';
replyButton.className = 'secondary-btn thread-reply-btn'; replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.textContent = '💬 Ответить'; replyButton.textContent = '⟳ Отразить';
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
revealCounters(); revealCounters();
@ -398,7 +373,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
shareButton.className = 'secondary-btn thread-share-btn'; shareButton.className = 'secondary-btn thread-share-btn';
shareButton.textContent = '↗ Отправить'; shareButton.textContent = '↗ Транслировать';
shareButton.addEventListener('click', async (event) => { shareButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
@ -479,6 +454,10 @@ export function render({ navigate, route }) {
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean'); appScreen?.classList.add('channels-scroll-clean');
const userIndicator = document.createElement('div');
userIndicator.className = 'card channels-user-chip';
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
const channelIndicator = document.createElement('div'); const channelIndicator = document.createElement('div');
channelIndicator.className = 'card channels-user-chip'; channelIndicator.className = 'card channels-user-chip';
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`; channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
@ -511,11 +490,7 @@ export function render({ navigate, route }) {
const requireSigningSession = () => { const requireSigningSession = () => {
const login = state.session.login; const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory; const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) { if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd }; return { login, storagePwd };
}; };
@ -587,7 +562,7 @@ export function render({ navigate, route }) {
leftAction: { label: '<', onClick: () => navigate(backRoute) }, leftAction: { label: '<', onClick: () => navigate(backRoute) },
}), }),
); );
screen.append(channelIndicator, statusBox); screen.append(userIndicator, channelIndicator, statusBox);
if (!selector) { if (!selector) {
const invalid = document.createElement('div'); const invalid = document.createElement('div');
@ -601,25 +576,7 @@ export function render({ navigate, route }) {
(async () => { (async () => {
try { try {
let resolvedMessage = selector.message; const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
if (selector.short?.ownerLogin && selector.short?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.short.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
));
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
if (!ownerBch || !Number.isFinite(resolvedMessage?.blockNumber)) {
throw new Error('Канал или сообщение не найдено.');
}
resolvedMessage = {
blockchainName: ownerBch,
blockNumber: resolvedMessage.blockNumber,
blockHash: normalizeRouteHash(resolvedMessage.blockHash),
};
}
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
skeleton.remove(); skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : []; const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
@ -648,7 +605,7 @@ export function render({ navigate, route }) {
if (focus) { if (focus) {
const focusWrap = document.createElement('div'); const focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus'; focusWrap.className = 'stack thread-block thread-block--focus';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: false })); focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
screen.append(focusWrap); screen.append(focusWrap);
} }

View File

@ -21,6 +21,9 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map(); const revealedCountersByRoute = new Map();
const seenFlushTimersByRoute = new Map();
const seenPendingByRoute = new Map();
const firstUnreadJumpByRoute = new Map();
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
@ -123,16 +126,44 @@ function buildAbsoluteRouteUrl(routePath = '') {
return url.toString(); return url.toString();
} }
function channelDescriptionParamKey(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
if (!owner || !Number.isFinite(rootNo)) return '';
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function parseDescriptionOverride(payload) {
if (!payload || typeof payload !== 'object') {
return { hasOverride: false, description: '' };
}
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
return { hasOverride: false, description: '' };
}
if (!rawValue) {
return { hasOverride: true, description: '' };
}
try {
const parsed = JSON.parse(rawValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const value = typeof parsed.v === 'string' ? parsed.v : '';
return { hasOverride: true, description: value.trim() };
}
} catch {
// legacy raw string value
}
return { hasOverride: true, description: rawValue };
}
function buildSelectorFromRoute(route, channelId) { function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {}; const params = route?.params || {};
if (params.ownerLogin && params.channelName) {
return {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
};
}
if (params.ownerBlockchainName) { if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber); const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) { if (rootBlockNumber != null) {
@ -155,17 +186,6 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) { function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return ''; if (!messageRef || !selector) return '';
const ownerLogin = String(selector.ownerLogin || '').trim();
const channelName = String(selector.channelName || '').trim();
if (ownerLogin && channelName) {
return [
'channel',
encodeRoutePart(ownerLogin),
encodeRoutePart(channelName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
].join('/');
}
return [ return [
'channel-thread-view', 'channel-thread-view',
encodeRoutePart(messageRef.blockchainName), encodeRoutePart(messageRef.blockchainName),
@ -375,6 +395,88 @@ function openAddMessageModal({ channelName, onSubmit }) {
if (textEl) textEl.focus(); if (textEl) textEl.focus();
} }
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-edit-description-modal">
<div class="modal-card stack">
<h3 class="modal-title">Описание канала</h3>
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<div class="meta-muted inline-error" id="channel-description-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-description-text');
const counterEl = root.querySelector('#channel-description-counter');
const errorEl = root.querySelector('#channel-description-error');
const submitEl = root.querySelector('#channel-description-submit');
const cancelEl = root.querySelector('#channel-description-cancel');
let inFlight = false;
const compute = () => {
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
const bytes = new TextEncoder().encode(value).length;
const ok = bytes <= 200;
return {
value,
bytes,
ok,
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
};
};
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
cancelEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
};
const close = () => {
root.innerHTML = '';
};
const updateValidation = () => {
const check = compute();
counterEl.textContent = `${check.bytes} / 200 байт`;
errorEl.textContent = check.error;
submitEl.disabled = inFlight || !check.ok;
return check;
};
cancelEl?.addEventListener('click', close);
textEl?.addEventListener('input', updateValidation);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(check.value);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
}
});
if (textEl) {
textEl.value = String(initialValue || '');
textEl.focus();
}
updateValidation();
}
function mapApiMessageToPost(message, selector, localNumber) { function mapApiMessageToPost(message, selector, localNumber) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber); const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash); const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
@ -400,6 +502,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
body: resolvedText || '(пусто)', body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0), likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0), repliesCount: Number(message?.repliesCount || 0),
viewCount: Number(message?.viewCount || 0),
seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message), timestampMs: resolveMessageTimestampMs(message),
messageRef, messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '', reactionState: messageRef ? getMessageReactionState(messageRef) : '',
@ -407,25 +511,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
} }
async function loadFromApi(route, channelId) { async function loadFromApi(route, channelId) {
let selector = buildSelectorFromRoute(route, channelId); const selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerLogin && selector?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.');
}
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
ownerLogin: selector.ownerLogin,
channelName: selector.channelName,
};
}
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) { if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.'); throw new Error('Не удалось определить канал из адреса страницы.');
} }
@ -434,15 +520,37 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : []; const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1)); const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
const unreadFromPayload = Number(payload.unreadCount || 0);
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
const paramKey = channelDescriptionParamKey(selector);
if (!ownerLogin || !paramKey) return sourceDescription;
try {
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
const override = parseDescriptionOverride(paramPayload);
return override.hasOverride ? override.description : sourceDescription;
} catch {
return sourceDescription;
}
};
const resolvedDescription = await readDescription();
return { return {
channel: { channel: {
name: payload.channel?.channelName || 'неизвестный канал', name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`, displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(), description: resolvedDescription,
ownerName: ownerLogin || 'неизвестно', ownerName: ownerLogin || 'неизвестно',
}, },
posts, posts,
unreadCount: Number.isFinite(unreadFromPayload)
? Math.max(0, unreadFromPayload)
: posts.filter((post) => post.seenByMe !== true).length,
firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(), isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector, selector,
}; };
@ -557,7 +665,11 @@ function renderPostCard(post, {
body.className = 'channel-message-body'; body.className = 'channel-message-body';
body.textContent = post.body; body.textContent = post.body;
card.append(topRow, body); const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(topRow, body, views);
const refKey = messageRefKey(post.messageRef); const refKey = messageRefKey(post.messageRef);
if (refKey) { if (refKey) {
@ -591,23 +703,19 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked'; const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked'); if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span> <span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span> <span class="channel-action-counter">${post.likesCount || 0}</span>
`; `;
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters(); revealCounters();
await longPressFeel(event.currentTarget, 130); await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true; likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label'); const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Лайк...'; if (labelEl) labelEl.textContent = 'Сияние...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
}); });
@ -616,7 +724,7 @@ function renderPostCard(post, {
replyButton.className = 'channel-action-item channel-action-reply'; replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = ` replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span> <span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Ответить</span> <span class="channel-action-label">Отразить</span>
`; `;
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
@ -646,7 +754,7 @@ function renderPostCard(post, {
shareButton.className = 'channel-action-item channel-action-share'; shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = ` shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span> <span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span> <span class="channel-action-label">Транслировать</span>
`; `;
shareButton.addEventListener('click', async (event) => { shareButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
@ -685,6 +793,22 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}); });
headActions.append(aboutButton); headActions.append(aboutButton);
if (channelData.isOwnChannel) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'secondary-btn small-btn';
editButton.textContent = '✎';
editButton.title = 'Редактировать описание';
editButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openEditDescriptionModal({
initialValue: channelData.channel.description || '',
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
});
});
headActions.append(editButton);
}
head.append(title); head.append(title);
head.append(owner, headActions); head.append(owner, headActions);
@ -692,11 +816,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
actionButton.className = channelData.isOwnChannel actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action' ? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action'; : 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал'; actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
const feed = document.createElement('div'); const feed = document.createElement('div');
feed.className = 'stack channel-feed'; feed.className = 'stack channel-feed';
const unreadDivider = document.createElement('div');
unreadDivider.className = 'channels-unread-divider';
unreadDivider.textContent = 'Непрочитанные сообщения';
const unreadJump = document.createElement('button');
unreadJump.type = 'button';
unreadJump.className = 'channels-unread-jump';
unreadJump.innerHTML = `
<span class="channels-unread-jump-icon" aria-hidden="true"></span>
<span class="channels-unread-jump-badge"></span>
`;
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
const postsByKey = new Map(); const postsByKey = new Map();
const unreadKeys = new Set();
let seenFlushInFlight = false;
let seenObserver = null;
if (channelData.posts.length) { if (channelData.posts.length) {
channelData.posts.forEach((post) => { channelData.posts.forEach((post) => {
@ -711,6 +849,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const key = messageRefKey(post.messageRef); const key = messageRefKey(post.messageRef);
if (key) { if (key) {
postsByKey.set(key, post); postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
} }
feed.append(row); feed.append(row);
}); });
@ -721,6 +860,101 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
feed.append(empty); feed.append(empty);
} }
const syncUnreadState = () => {
unreadKeys.clear();
postsByKey.forEach((post, key) => {
if (post.seenByMe !== true) unreadKeys.add(key);
});
};
const updateUnreadJump = () => {
const unreadCount = unreadKeys.size;
unreadJump.classList.toggle('is-visible', unreadCount > 0);
unreadJump.hidden = unreadCount <= 0;
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
};
const mountUnreadDivider = () => {
unreadDivider.remove();
if (!unreadKeys.size) return;
const firstUnread = channelData.posts.find((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
if (!firstUnreadKey) return;
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
if (target) {
feed.insertBefore(unreadDivider, target);
}
};
const routePending = (() => {
let bucket = seenPendingByRoute.get(routeKey);
if (!bucket) {
bucket = new Set();
seenPendingByRoute.set(routeKey, bucket);
}
return bucket;
})();
const scheduleSeenFlush = () => {
const oldTimer = seenFlushTimersByRoute.get(routeKey);
if (oldTimer) clearTimeout(oldTimer);
const timer = setTimeout(async () => {
seenFlushTimersByRoute.delete(routeKey);
if (seenFlushInFlight) return;
const pendingKeys = [...routePending].filter((key) => {
const post = postsByKey.get(key);
return !!post && post.seenByMe !== true;
});
if (!pendingKeys.length) return;
const refs = pendingKeys
.map((key) => parseMessageRefKey(key))
.filter(Boolean);
if (!refs.length) return;
pendingKeys.forEach((key) => routePending.delete(key));
seenFlushInFlight = true;
try {
await handlers.onMarkSeenBatch(refs);
refs.forEach((ref) => {
const key = messageRefKey(ref);
const post = key ? postsByKey.get(key) : null;
if (post) post.seenByMe = true;
});
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
} catch (error) {
refs.forEach((ref) => {
const key = messageRefKey(ref);
if (!key) return;
const node = feed.querySelector(`[data-message-key="${key}"]`);
if (node) seenObserver?.observe(node);
});
handlers.onSeenError?.(error);
} finally {
seenFlushInFlight = false;
if (routePending.size) scheduleSeenFlush();
}
}, 220);
seenFlushTimersByRoute.set(routeKey, timer);
};
unreadJump.addEventListener('click', () => {
const unreadPosts = channelData.posts.filter((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
const key = messageRefKey(targetPost?.messageRef);
if (!key) return;
const target = feed.querySelector(`[data-message-key="${key}"]`);
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
if (channelData.isOwnChannel) { if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => { actionButton.addEventListener('click', (event) => {
@ -731,7 +965,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}); });
}); });
} else { } else {
actionButton.addEventListener('click', handlers.onSubscribeChannel); actionButton.addEventListener('click', handlers.onUnfollowChannel);
} }
const backButton = document.createElement('button'); const backButton = document.createElement('button');
@ -739,11 +973,57 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам'; backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list')); backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton); screen.append(head, actionButton, feed, backButton, unreadJump);
if (state.session.login && channelData.selector && channelData.posts.length) {
seenObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
const key = String(entry.target?.dataset?.messageKey || '').trim();
if (!key) return;
const post = postsByKey.get(key);
if (!post || post.seenByMe === true) return;
routePending.add(key);
seenObserver?.unobserve(entry.target);
});
if (routePending.size) scheduleSeenFlush();
}, {
root: document.getElementById('app-screen') || null,
threshold: [0.6],
});
feed.querySelectorAll('[data-message-key]').forEach((node) => {
const key = String(node.dataset.messageKey || '').trim();
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
});
}
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
const firstUnreadCandidate = channelData.firstUnreadKey
|| (() => {
const first = channelData.posts.find((post) => post.seenByMe !== true);
return messageRefKey(first?.messageRef);
})();
if (firstUnreadCandidate) {
const previous = firstUnreadJumpByRoute.get(routeKey);
if (previous !== firstUnreadCandidate) {
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
}
} else {
firstUnreadJumpByRoute.delete(routeKey);
}
applyPendingScroll(screen, routeKey); applyPendingScroll(screen, routeKey);
return () => { return () => {
// noop seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
}; };
} }
@ -790,9 +1070,7 @@ export function render({ navigate, route }) {
const login = state.session.login; const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory; const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) { if (!login || !storagePwd) {
state.authReturnHash = window.location.hash || '#/channels-list'; throw new Error('Сессия недействительна. Выполните вход заново.');
navigate('login-view');
throw new Error('Для этого действия нужно войти');
} }
return { login, storagePwd }; return { login, storagePwd };
}; };
@ -839,6 +1117,21 @@ export function render({ navigate, route }) {
rerender(); rerender();
}; };
const onMarkSeenBatch = async (refs) => {
if (!Array.isArray(refs) || !refs.length) return;
const login = String(state.session.login || '').trim();
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
await authService.markChannelMessagesSeen({
login,
channel: {
ownerBlockchainName: routeSelector.ownerBlockchainName,
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
channelRootBlockHash: routeSelector.channelRootBlockHash,
},
messages: refs,
});
};
const onShare = async (routePath) => { const onShare = async (routePath) => {
try { try {
const routeToShare = String(routePath || '').trim(); const routeToShare = String(routePath || '').trim();
@ -852,7 +1145,7 @@ export function render({ navigate, route }) {
if (result === 'shared') showToast('Ссылка передана'); if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10); if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) { } catch (error) {
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.')); showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
} }
}; };
@ -875,6 +1168,25 @@ export function render({ navigate, route }) {
rerender(); rerender();
}; };
const onEditDescription = async (descriptionText) => {
const { login, storagePwd } = requireSigningSession();
const selector = routeSelector;
const param = channelDescriptionParamKey(selector);
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
await authService.addBlockUserParam({
login,
storagePwd,
param,
value,
});
softHaptic(10);
showToast('Описание канала сохранено');
rerender();
};
screen.append( screen.append(
renderHeader({ renderHeader({
title: '', title: '',
@ -917,14 +1229,19 @@ export function render({ navigate, route }) {
} }
}, },
onShare: onShare, onShare: onShare,
onSubscribeChannel: async (event) => { onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
animatePress(event?.currentTarget); animatePress(event?.currentTarget);
try { try {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.'); if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({ await authService.addBlockFollowChannel({
login, login,
@ -932,15 +1249,26 @@ export function render({ navigate, route }) {
targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash, targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: false, unfollow: true,
}); });
softHaptic(15); softHaptic(15);
showToast('Подписка на канал выполнена'); showToast('Отписка от канала выполнена');
navigate('channels-list');
} catch (error) { } catch (error) {
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.')); showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
} }
}, },
onMarkSeenBatch: async (refs) => {
try {
await onMarkSeenBatch(refs);
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
}
},
onSeenError: (error) => {
showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
},
}); });
} catch (error) { } catch (error) {
skeleton.remove(); skeleton.remove();

View File

@ -40,11 +40,6 @@ function normalizeLoginInput(value) {
} }
function buildChannelRouteFromSummary(summary, fallbackId) { function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
if (ownerLogin && channelName) {
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
}
const ownerBch = summary?.channel?.ownerBlockchainName; const ownerBch = summary?.channel?.ownerBlockchainName;
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber; const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash); const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
@ -411,117 +406,6 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
if (inputEl) inputEl.focus(); if (inputEl) inputEl.focus();
} }
function openChannelFinderModal({ navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-find-modal">
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
<h3 class="modal-title">Поиск каналов</h3>
<p class="meta-muted">Введите логин или формат login/channel</p>
<input id="channels-find-input" class="input" placeholder="login/channel" autocomplete="off" />
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
const inputEl = root.querySelector('#channels-find-input');
const suggestEl = root.querySelector('#channels-find-suggest');
const channelsEl = root.querySelector('#channels-find-list');
const errorEl = root.querySelector('#channels-find-error');
const close = () => { root.innerHTML = ''; };
const renderButtons = (container, values, onPick) => {
container.innerHTML = '';
if (!values.length) {
container.style.display = 'none';
return;
}
container.style.display = '';
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
});
};
const loadChannelsForLogin = async (login, filterChannel = '') => {
const ownerLogin = normalizeLoginInput(login);
if (!ownerLogin) return;
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
const needle = String(filterChannel || '').trim().toLowerCase();
const channels = rows
.map((item) => String(item?.channel?.channelName || '').trim())
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle))
.slice(0, 200)
.map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name }));
renderButtons(channelsEl, channels, (item) => {
close();
navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`);
});
};
const refresh = createDebounced(async () => {
const raw = String(inputEl?.value || '').trim();
errorEl.textContent = '';
if (!raw) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
channelsEl.innerHTML = '';
return;
}
const parts = raw.split('/');
const loginPrefix = normalizeLoginInput(parts[0] || '');
const channelFilter = String(parts[1] || '').trim();
try {
if (raw.includes('/')) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
await loadChannelsForLogin(loginPrefix, channelFilter);
return;
}
if (loginPrefix.length < 2) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
channelsEl.innerHTML = '';
return;
}
const logins = await authService.searchUsers(loginPrefix);
const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
label: login,
login,
}));
renderButtons(suggestEl, items, async (item) => {
inputEl.value = `${item.login}/`;
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
await loadChannelsForLogin(item.login, '');
});
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
}
}, 220);
root.querySelector('#channels-find-close')?.addEventListener('click', close);
inputEl?.addEventListener('input', refresh);
if (inputEl) inputEl.focus();
}
function mapMockGroups() { function mapMockGroups() {
const mapRow = (channel) => ({ const mapRow = (channel) => ({
...channel, ...channel,
@ -643,16 +527,6 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) { function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent'; wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
const text = document.createElement('p');
text.className = 'meta-muted';
if (activeTab === 'subscriptions') {
text.textContent = 'Вы пока не подписаны на каналы.';
} else if (activeTab === 'my') {
text.textContent = 'У вас пока нет каналов.';
} else {
text.textContent = 'Пока нет каналов авторов.';
}
wrap.append(text);
return wrap; return wrap;
} }
@ -1022,9 +896,14 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
} }
if (tab === 'authors') { if (tab === 'authors') {
button.textContent = '🔍 Поиск каналов'; button.textContent = 'Подписаться на автора';
button.className = baseClass; button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate }); button.onclick = () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на автора',
submitLabel: 'Подписаться',
onSuccess: onReload,
});
return; return;
} }
@ -1075,7 +954,7 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState(); const notificationsState = readChannelNotificationsState();
const listState = { const listState = {
activeTab: 'subscriptions', activeTab: 'my',
openMenuId: null, openMenuId: null,
notificationsState, notificationsState,
revealedCounters: new Set(), revealedCounters: new Set(),
@ -1122,8 +1001,8 @@ export function render({ navigate }) {
}; };
const tabItems = [ const tabItems = [
{ key: 'subscriptions', label: 'Каналы' },
{ key: 'my', label: 'Мои' }, { key: 'my', label: 'Мои' },
{ key: 'subscriptions', label: 'Подписки' },
{ key: 'authors', label: 'Авторы' }, { key: 'authors', label: 'Авторы' },
]; ];

View File

@ -151,7 +151,7 @@ export function render({ navigate }) {
let currentFields = []; let currentFields = [];
let currentToggles = []; let currentToggles = [];
let currentGender = PROFILE_GENDER_UNKNOWN; let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]'); const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]'); const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
@ -226,9 +226,7 @@ export function render({ navigate }) {
login, login,
firstName, firstName,
lastName, lastName,
avatar: currentAvatar?.txId avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
: null,
size: 'large', size: 'large',
className: 'profile-avatar', className: 'profile-avatar',
})); }));
@ -576,7 +574,7 @@ export function render({ navigate }) {
currentFields = snapshot.fields; currentFields = snapshot.fields;
currentToggles = snapshot.toggles; currentToggles = snapshot.toggles;
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN; currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
syncIdentity(); syncIdentity();
renderFields(currentFields); renderFields(currentFields);

View File

@ -88,7 +88,7 @@ export function render({ navigate }) {
let currentFields = []; let currentFields = [];
let currentToggles = []; let currentToggles = [];
let currentGender = 'unknown'; let currentGender = 'unknown';
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
function syncIdentity() { function syncIdentity() {
if (!identityEl) return; if (!identityEl) return;
@ -109,9 +109,7 @@ export function render({ navigate }) {
login, login,
firstName, firstName,
lastName, lastName,
avatar: currentAvatar?.txId avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
: null,
size: 'large', size: 'large',
className: 'profile-avatar', className: 'profile-avatar',
})); }));
@ -163,7 +161,7 @@ export function render({ navigate }) {
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : []; currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : []; currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
currentGender = snapshot.gender || 'unknown'; currentGender = snapshot.gender || 'unknown';
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 }; currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
syncIdentity(); syncIdentity();
updateAvatarUi(); updateAvatarUi();
updateTogglesUi(); updateTogglesUi();

View File

@ -130,13 +130,7 @@ export function render({ navigate }) {
setAuthInfo(isLoginFlow setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».` ? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`); : `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
const nextHash = String(state.authReturnHash || '').trim(); navigate('profile-view');
state.authReturnHash = '';
if (nextHash.startsWith('#/')) {
navigate(nextHash.slice(2));
} else {
navigate('profile-view');
}
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.'); const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message); setAuthError(message);

View File

@ -19,7 +19,6 @@ export function render({ navigate }) {
solanaServer: state.entrySettings.solanaServer, solanaServer: state.entrySettings.solanaServer,
shineServer: state.entrySettings.shineServer, shineServer: state.entrySettings.shineServer,
arweaveServer: state.entrySettings.arweaveServer, arweaveServer: state.entrySettings.arweaveServer,
callPreflightTimeoutMs: Number(state.entrySettings.callPreflightTimeoutMs || 6000),
statuses: { ...state.entrySettings.statuses }, statuses: { ...state.entrySettings.statuses },
}; };
@ -109,33 +108,6 @@ export function render({ navigate }) {
body.append(block); body.append(block);
}); });
const callSettings = document.createElement('div');
callSettings.className = 'stack';
const callTimeoutLabel = document.createElement('label');
callTimeoutLabel.className = 'field-label';
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
const callTimeoutInput = document.createElement('input');
callTimeoutInput.className = 'input';
callTimeoutInput.type = 'number';
callTimeoutInput.min = '1000';
callTimeoutInput.max = '20000';
callTimeoutInput.step = '500';
callTimeoutInput.value = String(Math.max(1000, Math.min(20000, Number(draft.callPreflightTimeoutMs) || 6000)));
callTimeoutInput.addEventListener('input', () => {
const n = Number(callTimeoutInput.value);
if (!Number.isFinite(n)) return;
draft.callPreflightTimeoutMs = Math.max(1000, Math.min(20000, Math.round(n)));
});
const callTimeoutHint = document.createElement('p');
callTimeoutHint.className = 'meta-muted';
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
body.append(callSettings);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';

View File

@ -50,42 +50,6 @@ export function getRoute() {
return { pageId, params: { channelId: dynamicId || '' } }; return { pageId, params: { channelId: dynamicId || '' } };
} }
if (pageId === 'channel') {
// Новый короткий формат:
// #/channel/{login}/{channelName}
// #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash}
const ownerLogin = decodePart(segments[1] || '');
const channelName = decodePart(segments[2] || '');
const messageBlockNumber = segments[3] || '';
const messageBlockHash = segments[4] || '';
if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) {
return {
pageId: 'channel-thread-view',
params: {
ownerLogin,
channelName,
messageBlockNumber,
messageBlockHash,
// поддержка старого контракта страницы треда
messageBlockchainName: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
channelRootBlockHash: '',
},
};
}
return {
pageId: 'channel-view',
params: {
ownerLogin,
channelName,
channelId: '',
},
};
}
if (pageId === 'channel-thread-view') { if (pageId === 'channel-thread-view') {
return { return {
pageId, pageId,

View File

@ -1,9 +1,4 @@
import { import { buildArweaveDataUrl, validateArweaveTxId } from './arweave-file-service.js';
buildArweaveDataUrl,
sha256HexFromArrayBuffer,
validateArweaveTxId,
validateSha256Hex,
} from './arweave-file-service.js';
const DB_NAME = 'shine-ui-avatar-cache'; const DB_NAME = 'shine-ui-avatar-cache';
const DB_VERSION = 1; const DB_VERSION = 1;
@ -60,14 +55,6 @@ async function putRecord(record) {
}); });
} }
async function verifyBlobSha256(blob, expectedSha256Hex) {
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
if (!validateSha256Hex(expected)) return true;
if (!(blob instanceof Blob)) return false;
const actual = await sha256HexFromArrayBuffer(await blob.arrayBuffer());
return actual === expected;
}
async function getAllRecords() { async function getAllRecords() {
return withStore('readonly', (store, _tx, resolve, reject) => { return withStore('readonly', (store, _tx, resolve, reject) => {
const req = store.getAll(); const req = store.getAll();
@ -159,41 +146,22 @@ function detectImageMime(bytes) {
return ''; return '';
} }
async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = '' }) { async function getBlobFromCacheOrGateway({ gateway, txId }) {
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
try { try {
const cached = await getRecord(txId); const cached = await getRecord(txId);
if (cached?.blob instanceof Blob) { if (cached?.blob instanceof Blob) {
if (!validateSha256Hex(expected)) return cached.blob; return cached.blob;
const cacheSha = String(cached?.sha256Hex || '').trim().toLowerCase();
if (cacheSha && cacheSha === expected) {
return cached.blob;
}
const ok = await verifyBlobSha256(cached.blob, expected);
if (ok) {
return cached.blob;
}
// кэш повреждён или не совпадает с ожидаемым хэшем: удаляем и перекачиваем
await deleteRecords([txId]);
} }
} catch { } catch {
// ignore IndexedDB errors and fallback to fetch // ignore IndexedDB errors and fallback to fetch
} }
const blob = await fetchAvatarBlob({ gateway, txId }); const blob = await fetchAvatarBlob({ gateway, txId });
if (validateSha256Hex(expected)) {
const ok = await verifyBlobSha256(blob, expected);
if (!ok) {
throw new Error('SHA256_MISMATCH');
}
}
const computedSha256Hex = await sha256HexFromArrayBuffer(await blob.arrayBuffer());
const record = { const record = {
txId, txId,
blob, blob,
contentType: String(blob.type || 'application/octet-stream'), contentType: String(blob.type || 'application/octet-stream'),
sizeBytes: Number(blob.size || 0), sizeBytes: Number(blob.size || 0),
sha256Hex: computedSha256Hex,
cachedAtMs: Date.now(), cachedAtMs: Date.now(),
}; };
try { try {
@ -205,16 +173,12 @@ async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = ''
return blob; return blob;
} }
export async function getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex = '' }) { export async function getCachedAvatarObjectUrl({ gateway, txId }) {
const cleanTxId = String(txId || '').trim(); const cleanTxId = String(txId || '').trim();
if (!validateArweaveTxId(cleanTxId)) { if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave'); throw new Error('Некорректный Transaction ID Arweave');
} }
const blob = await getBlobFromCacheOrGateway({ const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
gateway,
txId: cleanTxId,
expectedSha256Hex,
});
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
} }

View File

@ -4,7 +4,6 @@ const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768; const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86; const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/; const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
const SHA256_HEX_RE = /^[A-Fa-f0-9]{64}$/;
let arweaveLibPromise = null; let arweaveLibPromise = null;
@ -93,64 +92,29 @@ function canvasToBlob(canvas, type, quality) {
}); });
} }
function bytesToHex(bytes) {
return Array.from(bytes, (item) => item.toString(16).padStart(2, '0')).join('');
}
export function validateArweaveTxId(txId) { export function validateArweaveTxId(txId) {
const value = String(txId || '').trim(); const value = String(txId || '').trim();
return TX_ID_RE.test(value); return TX_ID_RE.test(value);
} }
export function validateSha256Hex(sha256Hex) { export function buildArweaveAvatarValue(txId) {
const value = String(sha256Hex || '').trim();
return SHA256_HEX_RE.test(value);
}
export async function sha256HexFromArrayBuffer(buffer) {
if (!(buffer instanceof ArrayBuffer)) throw new Error('Некорректные данные для SHA256');
const digest = await crypto.subtle.digest('SHA-256', buffer);
return bytesToHex(new Uint8Array(digest));
}
export function buildArweaveAvatarValue(txId, sha256Hex = '') {
const cleanTxId = String(txId || '').trim(); const cleanTxId = String(txId || '').trim();
if (!validateArweaveTxId(cleanTxId)) { if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave'); throw new Error('Некорректный Transaction ID Arweave');
} }
const cleanSha = String(sha256Hex || '').trim().toLowerCase(); return `AR:${cleanTxId}`;
if (!cleanSha) return `AR:${cleanTxId}`;
if (!validateSha256Hex(cleanSha)) {
throw new Error('Некорректный SHA256 хэш аватара');
}
return `SHA256:${cleanSha},AR:${cleanTxId}`;
} }
export function parseArweaveAvatarValue(value) { export function parseArweaveAvatarValue(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) { if (!raw.startsWith('AR:')) {
return { ok: false, network: '', txId: '', sha256Hex: '' }; return { ok: false, network: '', txId: '' };
}
const arMatch = raw.match(/(?:^|,)\s*AR:([A-Za-z0-9_-]{43})\s*(?:,|$)/);
let txId = String(arMatch?.[1] || '').trim();
if (!txId) {
// fallback для старых/кривых значений без запятых: "...AR:<txid>..."
const fallbackAr = raw.match(/AR:([A-Za-z0-9_-]{43})/);
txId = String(fallbackAr?.[1] || '').trim();
} }
const txId = raw.slice(3).trim();
if (!validateArweaveTxId(txId)) { if (!validateArweaveTxId(txId)) {
return { ok: false, network: '', txId: '', sha256Hex: '' }; return { ok: false, network: '', txId: '' };
} }
return { ok: true, network: 'AR', txId };
const shaMatch = raw.match(/(?:^|,)\s*SHA256:([A-Fa-f0-9]{64})\s*(?:,|$)/);
let sha256Hex = String(shaMatch?.[1] || '').trim().toLowerCase();
if (!sha256Hex) {
const fallbackSha = raw.match(/SHA256:([A-Fa-f0-9]{64})/);
sha256Hex = String(fallbackSha?.[1] || '').trim().toLowerCase();
}
return { ok: true, network: 'AR', txId, sha256Hex };
} }
export function buildArweaveDataUrl({ gateway, txId }) { export function buildArweaveDataUrl({ gateway, txId }) {
@ -245,8 +209,6 @@ export async function prepareAvatarImageFile(file) {
} }
const optimizedFile = blobToFile(blob, fileName, contentType); const optimizedFile = blobToFile(blob, fileName, contentType);
const optimizedArrayBuffer = await optimizedFile.arrayBuffer();
const sha256Hex = await sha256HexFromArrayBuffer(optimizedArrayBuffer);
return { return {
file: optimizedFile, file: optimizedFile,
originalSizeBytes: Number(file.size || 0), originalSizeBytes: Number(file.size || 0),
@ -256,7 +218,6 @@ export async function prepareAvatarImageFile(file) {
width, width,
height, height,
contentType, contentType,
sha256Hex,
}; };
} catch (error) { } catch (error) {
if (error instanceof Error) throw error; if (error instanceof Error) throw error;

View File

@ -92,6 +92,27 @@ function opError(op, response) {
return error; return error;
} }
function isLegacyCreateChannelFormatError(error) {
const code = String(error?.code || '').trim().toUpperCase();
const text = String(error?.message || '').toLowerCase();
if (code === 'BAD_BLOCK_FORMAT') return true;
return (
text.includes('unknown body type/version') ||
text.includes('unknown tech body type/version/subtype') ||
text.includes('bad_block_format')
);
}
function channelDescriptionParamKeyFromSelector(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = String(selector?.channelRootBlockHash || '').trim().toLowerCase();
if (!owner || !Number.isFinite(rootNo) || rootNo < 0 || !/^[0-9a-f]{64}$/.test(rootHash)) {
return '';
}
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function makeClientInfo() { function makeClientInfo() {
const ua = navigator.userAgent || 'unknown'; const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50); return ua.slice(0, 50);
@ -395,6 +416,26 @@ function makeConnectionBodyBytes({
); );
} }
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 normalizeChannelDescription(value) { function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' '); const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const bytes = utf8Bytes(text); const bytes = utf8Bytes(text);
@ -699,12 +740,7 @@ export class AuthService {
} }
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') { async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
const normalizedChannel = { const payload = { channel, limit, sort };
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
};
const payload = { channel: normalizedChannel, limit, sort };
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin; if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload); const response = await this.ws.request('GetChannelMessages', payload);
@ -713,12 +749,7 @@ export class AuthService {
} }
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') { async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = { const payload = { message, depthUp, depthDown, limitChildrenPerNode };
blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(),
};
const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin; if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload); const response = await this.ws.request('GetMessageThread', payload);
@ -726,6 +757,17 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async markChannelMessagesSeen({ login, channel, messages }) {
const cleanLogin = String(login || '').trim();
const refs = Array.isArray(messages) ? messages : [];
const payload = { channel, messages: refs };
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('MarkChannelMessagesSeen', payload);
if (response.status !== 200) throw opError('MarkChannelMessagesSeen', response);
return response.payload || {};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) { async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const cleanLogin = (login || '').trim(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock'); if (!cleanLogin) throw new Error('Missing login for AddBlock');
@ -1047,21 +1089,44 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1; thisLineNumber = createdChannels.length + 1;
} }
const payload = await this.addBlockSigned({ const submitCreate = async (useV2) => {
login: cleanLogin, const bodyBytes = useV2
storagePwd, ? makeCreateChannelBodyV2Bytes({
msgType: MSG_TYPE_TECH, lineCode: 0,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL, prevLineNumber,
msgVersion: CREATE_CHANNEL_BODY_VERSION, prevLineHashHex,
bodyBytes: makeCreateChannelBodyV2Bytes({ thisLineNumber,
lineCode: 0, channelName: cleanChannelName,
prevLineNumber, channelDescription: cleanChannelDescription,
prevLineHashHex, })
thisLineNumber, : makeCreateChannelBodyBytes({
channelName: cleanChannelName, lineCode: 0,
channelDescription: cleanChannelDescription, prevLineNumber,
}), prevLineHashHex,
}); thisLineNumber,
channelName: cleanChannelName,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
bodyBytes,
});
};
let payload;
let usedLegacyDescriptionFallback = false;
let savedDescriptionViaUserParam = false;
try {
payload = await submitCreate(true);
} catch (error) {
if (!isLegacyCreateChannelFormatError(error)) throw error;
payload = await submitCreate(false);
usedLegacyDescriptionFallback = true;
}
const selector = { const selector = {
ownerBlockchainName: blockchainName, ownerBlockchainName: blockchainName,
@ -1069,8 +1134,24 @@ export class AuthService {
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64), channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
}; };
if (usedLegacyDescriptionFallback && cleanChannelDescription) {
const param = channelDescriptionParamKeyFromSelector(selector);
if (!param) {
throw new Error('Не удалось сохранить описание канала: некорректный идентификатор канала.');
}
await this.addBlockUserParam({
login: cleanLogin,
storagePwd,
param,
value: JSON.stringify({ v: cleanChannelDescription }),
});
savedDescriptionViaUserParam = true;
}
return { return {
...payload, ...payload,
usedLegacyDescriptionFallback,
savedDescriptionViaUserParam,
channel: { channel: {
...selector, ...selector,
}, },

View File

@ -1,4 +1,4 @@
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js'; import { addSystemChatMessage, authService } from '../state.js';
const TYPES = { const TYPES = {
INVITE: 100, INVITE: 100,
@ -31,47 +31,6 @@ function nowMs() {
return Date.now(); return Date.now();
} }
function resolveCallPreflightTimeoutMs() {
const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000);
return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000));
}
function isSessionReadyForCall() {
const wsOpen = Boolean(authService?.ws?.ws && authService.ws.ws.readyState === WebSocket.OPEN);
const hasSession = Boolean(state?.session?.isAuthorized && state?.session?.login && state?.session?.sessionId);
return wsOpen && hasSession;
}
async function withTimeout(promise, timeoutMs, timeoutMessage = 'timeout') {
let timerId = 0;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timerId = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timerId) window.clearTimeout(timerId);
}
}
async function ensureSessionForCall({ timeoutMs, force = false } = {}) {
if (!force && isSessionReadyForCall()) return true;
const login = String(state?.session?.login || '').trim();
const sessionId = String(state?.session?.sessionId || '').trim();
if (!login || !sessionId) return false;
try {
await withTimeout(authService.ws.open(), timeoutMs, 'call_preflight_ws_timeout');
const resumed = await withTimeout(authService.resumeSession(login, sessionId), timeoutMs, 'call_preflight_resume_timeout');
authorizeSession(resumed);
return true;
} catch {
return false;
}
}
function makeCallId() { function makeCallId() {
return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`; return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`;
} }
@ -894,23 +853,8 @@ async function finalizeCall(call, {
call.phase = 'ended'; call.phase = 'ended';
call.statusText = 'Звонок завершён'; call.statusText = 'Звонок завершён';
if (String(localReasonCode || '') === 'busy') {
call.statusText = 'Пользователь занят';
}
notifyCallState(); notifyCallState();
const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0;
if (finalHoldMs > 0) {
window.setTimeout(() => {
calls.delete(call.callId);
if (activeCallId === call.callId) {
activeCallId = '';
}
notifyCallState();
}, finalHoldMs);
return;
}
calls.delete(call.callId); calls.delete(call.callId);
if (activeCallId === call.callId) { if (activeCallId === call.callId) {
activeCallId = ''; activeCallId = '';
@ -1120,84 +1064,6 @@ function ensureIncomingNotification(peerLogin) {
} catch {} } catch {}
} }
function isIncomingCallPushFresh(payload) {
const expiresAtMs = Number(payload?.expiresAtMs || 0);
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0 && Date.now() > expiresAtMs) {
return false;
}
return true;
}
async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
const callId = String(payload?.callId || '').trim();
const fromLogin = String(payload?.fromLogin || '').trim();
const fromSessionId = String(payload?.fromSessionId || '').trim();
if (!callId || !fromLogin || !fromSessionId) return null;
if (activeCallId && activeCallId !== callId) {
try {
await authService.callSignalToSession({
toLogin: fromLogin,
targetSessionId: fromSessionId,
callId,
type: TYPES.DECLINE_BUSY,
data: 'busy',
});
} catch {}
return null;
}
let call = getCall(callId);
if (!call) {
call = {
callId,
peerLogin: fromLogin,
direction: 'in',
phase: 'incoming',
statusText: `Вам звонит ${fromLogin}`,
remoteSessionId: fromSessionId,
timers: {},
startedAtMs: nowMs(),
connectedAtMs: 0,
pc: null,
localStream: null,
audioSenders: [],
muted: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
debugMode: false,
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
};
calls.set(callId, call);
} else if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
}
activeCallId = callId;
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
ensureIncomingNotification(fromLogin);
try {
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
} catch {}
if (!call.timers.incoming20s) {
call.timers.incoming20s = setTimeout(async () => {
if (!calls.has(callId)) return;
try {
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
} catch {}
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
}, 20000);
}
return call;
}
export function setCallDebugReporter(fn) { export function setCallDebugReporter(fn) {
debugReporter = typeof fn === 'function' ? fn : null; debugReporter = typeof fn === 'function' ? fn : null;
} }
@ -1355,12 +1221,6 @@ export async function startOutgoingCall(peerLogin) {
} }
const callId = makeCallId(); const callId = makeCallId();
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
if (!preflightOk) {
throw new Error('Сервер временно недоступен');
}
const call = { const call = {
callId, callId,
peerLogin: cleanPeer, peerLogin: cleanPeer,
@ -1406,26 +1266,75 @@ export async function startOutgoingCall(peerLogin) {
try { try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
} catch (error) { } catch (error) {
const text = String(error?.message || '').toUpperCase();
const isNotAuth = text.includes('NOT_AUTHENTICATED');
if (isNotAuth) {
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
if (recovered) {
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
return;
} catch {}
}
await finalizeCall(call, { localReasonCode: 'error', debugReason: 'invite_failed:not_authenticated_after_retry' });
throw new Error('Сервер временно недоступен');
}
await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` }); await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` });
throw error; throw error;
} }
} }
export async function handleIncomingCallInvite(evt) { export async function handleIncomingCallInvite(evt) {
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' }); const payload = evt?.payload || {};
const callId = String(payload.callId || '').trim();
const fromLogin = String(payload.fromLogin || '').trim();
const fromSessionId = String(payload.fromSessionId || '').trim();
if (!callId || !fromLogin || !fromSessionId) return;
if (activeCallId && activeCallId !== callId) {
try {
await authService.callSignalToSession({
toLogin: fromLogin,
targetSessionId: fromSessionId,
callId,
type: TYPES.DECLINE_BUSY,
data: 'busy',
});
} catch {}
return;
}
let call = getCall(callId);
if (!call) {
call = {
callId,
peerLogin: fromLogin,
direction: 'in',
phase: 'incoming',
statusText: `Вам звонит ${fromLogin}`,
remoteSessionId: fromSessionId,
timers: {},
startedAtMs: nowMs(),
connectedAtMs: 0,
pc: null,
localStream: null,
audioSenders: [],
muted: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
debugMode: false,
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
};
calls.set(callId, call);
}
activeCallId = callId;
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
ensureIncomingNotification(fromLogin);
try {
await sendSignal(call, TYPES.RINGING, 'ringing');
} catch {}
call.timers.incoming20s = setTimeout(async () => {
if (!calls.has(callId)) return;
try {
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
} catch {}
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
}, 20000);
} }
export async function acceptIncomingCall() { export async function acceptIncomingCall() {
@ -1625,37 +1534,3 @@ export async function hangupActiveCall() {
notifyRemoteHangup: true, notifyRemoteHangup: true,
}); });
} }
export async function handleIncomingCallPush(payload = {}) {
if (!isIncomingCallPushFresh(payload)) return;
await handleIncomingInvitePayload(payload, { source: 'push' });
}
export async function handleStopCallPush(payload = {}) {
const callId = String(payload?.callId || '').trim();
if (!callId) return;
const call = getCall(callId);
if (!call) return;
const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push';
await finalizeCall(call, {
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
debugReason: `stop_call_push:${reason}`,
});
}
export async function handleCallPushAction(action, payload = {}) {
const normalized = String(action || '').trim().toLowerCase();
if (normalized !== 'accept' && normalized !== 'decline') return;
if (!isIncomingCallPushFresh(payload)) return;
const timeoutMs = resolveCallPreflightTimeoutMs();
const ok = await ensureSessionForCall({ timeoutMs, force: false });
if (!ok) {
throw new Error('Не удалось подключиться, вызов завершён');
}
await handleIncomingCallPush(payload);
if (normalized === 'accept') {
await acceptIncomingCall();
return;
}
await declineIncomingCall();
}

View File

@ -1,10 +1,10 @@
const MIN_LEN = 3; const MIN_LEN = 3;
const MAX_LEN = 32; const MAX_LEN = 32;
const ALLOWED_CHARS_RE = /^[A-Za-z0-9_-]+$/; const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
export function normalizeChannelDisplayName(value) { export function normalizeChannelDisplayName(value) {
if (value == null) return ''; if (value == null) return '';
return String(value).trim(); return String(value).trim().replace(/\s+/g, ' ');
} }
export function normalizeChannelDescription(value) { export function normalizeChannelDescription(value) {
@ -16,9 +16,24 @@ export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value); const normalized = normalizeChannelDisplayName(value);
if (!normalized) return ''; if (!normalized) return '';
const lowered = normalized.toLowerCase(); const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
if (!ALLOWED_CHARS_RE.test(lowered)) return ''; let out = '';
return lowered; 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) { export function validateChannelDisplayName(value) {
@ -58,7 +73,7 @@ export function channelNameErrorText(code) {
case 'too_long': case 'too_long':
return 'Название слишком длинное: максимум 32 символа.'; return 'Название слишком длинное: максимум 32 символа.';
case 'bad_chars': case 'bad_chars':
return 'Разрешены только латиница, цифры, _ и -.'; return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
case 'reserved': case 'reserved':
return 'Название "0" зарезервировано.'; return 'Название "0" зарезервировано.';
default: default:

View File

@ -199,12 +199,7 @@ export async function loadUserProfileCard(login) {
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown', gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
official: Boolean(toggles.official), official: Boolean(toggles.official),
shine: Boolean(toggles.shine), shine: Boolean(toggles.shine),
avatar: snapshot?.avatar?.txId avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim() } : null,
? {
ar: String(snapshot.avatar.txId).trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
}; };
} }

View File

@ -1,10 +1,5 @@
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { import { buildArweaveAvatarValue, parseArweaveAvatarValue, validateArweaveTxId } from './arweave-file-service.js';
buildArweaveAvatarValue,
parseArweaveAvatarValue,
validateArweaveTxId,
validateSha256Hex,
} from './arweave-file-service.js';
export const profileFieldDefs = [ export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' }, { key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
@ -128,17 +123,15 @@ export async function loadProfileSnapshot(login) {
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || ''); const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
const avatar = parsedAvatar.ok const avatar = parsedAvatar.ok
? { ? {
value: buildArweaveAvatarValue(parsedAvatar.txId, parsedAvatar.sha256Hex), value: buildArweaveAvatarValue(parsedAvatar.txId),
source: 'arweave', source: 'arweave',
txId: parsedAvatar.txId, txId: parsedAvatar.txId,
sha256Hex: parsedAvatar.sha256Hex || '',
timeMs: latestAvatar?.timeMs || 0, timeMs: latestAvatar?.timeMs || 0,
} }
: { : {
value: '', value: '',
source: '', source: '',
txId: '', txId: '',
sha256Hex: '',
timeMs: latestAvatar?.timeMs || 0, timeMs: latestAvatar?.timeMs || 0,
}; };
@ -182,14 +175,10 @@ export async function saveProfileGender(login, gender) {
}); });
} }
export async function saveProfileAvatarArweave(login, txId, sha256Hex) { export async function saveProfileAvatarArweave(login, txId) {
const cleanTxId = String(txId || '').trim(); const cleanTxId = String(txId || '').trim();
const cleanSha = String(sha256Hex || '').trim().toLowerCase();
if (!validateArweaveTxId(cleanTxId)) { if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave'); throw new Error('Некорректный Transaction ID Arweave');
} }
if (!validateSha256Hex(cleanSha)) { await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId));
throw new Error('Некорректный SHA256 хэш аватара');
}
await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId, cleanSha));
} }

View File

@ -80,7 +80,6 @@ const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl();
const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com'; const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com';
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws'; const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
function loadStoredSession() { function loadStoredSession() {
try { try {
@ -147,7 +146,6 @@ function persistEntrySettings(settings) {
solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER), solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER),
shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER), shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER),
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
statuses: { statuses: {
solanaServer: String(settings?.statuses?.solanaServer || 'idle'), solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
shineServer: String(settings?.statuses?.shineServer || 'idle'), shineServer: String(settings?.statuses?.shineServer || 'idle'),
@ -210,7 +208,6 @@ function createInitialState({ withStoredSession = true } = {}) {
solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER), solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER),
shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer), shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer),
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
statuses: { statuses: {
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'), solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'), shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),
@ -252,7 +249,6 @@ function createInitialState({ withStoredSession = true } = {}) {
error: '', error: '',
info: '', info: '',
}, },
authReturnHash: '',
sessions: [], sessions: [],
channelsFeed: null, channelsFeed: null,
channelsIndex: {}, channelsIndex: {},

View File

@ -360,7 +360,35 @@ public final class DatabaseInitializer {
ON message_stats (to_login); ON message_stats (to_login);
"""); """);
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target) // 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
first_seen_at_ms INTEGER NOT NULL,
UNIQUE (
viewer_login,
to_bch_name,
to_block_number,
to_block_hash
)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
ON message_views_state (viewer_login, to_bch_name);
""");
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS reactions_state ( CREATE TABLE IF NOT EXISTS reactions_state (
from_login TEXT NOT NULL, from_login TEXT NOT NULL,

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController { public final class SqliteDbController {
private static volatile SqliteDbController instance; private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 2; private static final int LATEST_SCHEMA_VERSION = DatabaseInitializer.SCHEMA_VERSION_1;
private final String jdbcUrl; private final String jdbcUrl;
@ -84,7 +84,6 @@ public final class SqliteDbController {
private void applyMigration(int targetVersion) { private void applyMigration(int targetVersion) {
switch (targetVersion) { switch (targetVersion) {
case 1 -> migrateToV1(); case 1 -> migrateToV1();
case 2 -> migrateToV2();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
} }
} }
@ -124,29 +123,6 @@ public final class SqliteDbController {
} }
} }
private void migrateToV2() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
st.execute("PRAGMA foreign_keys = OFF");
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
setSchemaVersion(c, 2);
st.execute("PRAGMA foreign_keys = ON");
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v2 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v2 failed", e);
}
}
private int getCurrentSchemaVersion() { private int getCurrentSchemaVersion() {
try (Connection c = DriverManager.getConnection(jdbcUrl)) { try (Connection c = DriverManager.getConnection(jdbcUrl)) {
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) { if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
@ -207,6 +183,23 @@ public final class SqliteDbController {
"""); """);
} }
private static void ensureMessageViewsStateTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
first_seen_at_ms INTEGER NOT NULL,
UNIQUE (
viewer_login,
to_bch_name,
to_block_number,
to_block_hash
)
);
""");
}
private static void createConnectionsStateTable(Statement st) throws SQLException { private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
@ -253,6 +246,17 @@ public final class SqliteDbController {
"""); """);
} }
private static void ensureMessageViewsIndexes(Statement st) throws SQLException {
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
ON message_views_state (viewer_login, to_bch_name);
""");
}
private static void ensureChannelNamesStateTable(Statement st) throws SQLException { private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS channel_names_state ( CREATE TABLE IF NOT EXISTS channel_names_state (

View File

@ -7,13 +7,13 @@ public final class ChannelNameRules {
private static final int MIN_DISPLAY_NAME_LENGTH = 3; private static final int MIN_DISPLAY_NAME_LENGTH = 3;
private static final int MAX_DISPLAY_NAME_LENGTH = 32; private static final int MAX_DISPLAY_NAME_LENGTH = 32;
private static final Pattern DISPLAY_ALLOWED_PATTERN = private static final Pattern DISPLAY_ALLOWED_PATTERN =
Pattern.compile("^[A-Za-z0-9_-]+$"); Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
private ChannelNameRules() {} private ChannelNameRules() {}
public static String normalizeDisplayName(String value) { public static String normalizeDisplayName(String value) {
if (value == null) return ""; if (value == null) return "";
return value.trim(); return value.trim().replaceAll("\\s+", " ");
} }
public static String requireValidDisplayNameForCreate(String rawName) { public static String requireValidDisplayNameForCreate(String rawName) {
@ -40,10 +40,45 @@ public final class ChannelNameRules {
throw new IllegalArgumentException("channelName is blank"); throw new IllegalArgumentException("channelName is blank");
} }
String lowered = normalized.toLowerCase(Locale.ROOT); String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) { StringBuilder slug = new StringBuilder(lowered.length());
throw new IllegalArgumentException("channelName contains unsupported characters"); 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);
} }
return lowered;
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;
} }
} }

View File

@ -49,15 +49,18 @@ import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstra
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;
import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
@ -67,6 +70,7 @@ import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
@ -129,6 +133,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
@ -140,6 +145,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()), Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()), Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
@ -184,6 +190,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
@ -195,6 +202,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class), Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class), Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),

View File

@ -142,7 +142,6 @@ 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_zero_writes_disabled" -> "Запись в канал 0 временно отключена";
case "channel_name_already_exists" -> "Такое название канала уже занято"; case "channel_name_already_exists" -> "Такое название канала уже занято";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
@ -338,17 +337,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
prevLineHash32 = bl.prevLineBlockHash32(); prevLineHash32 = bl.prevLineBlockHash32();
thisLineNumber = bl.lineSeq(); thisLineNumber = bl.lineSeq();
// Канал 0 сохраняем как технический root, но публикации в него пока не принимаем.
// Это правило защищает от "случайных" постов в дефолтный канал.
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
if (msgType == 1
&& msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
&& lineCode != null
&& lineCode == 0) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_zero_writes_disabled", serverLastNum, serverLastHashHex);
}
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
if (prevLineNumber != null && prevLineNumber == -1) { if (prevLineNumber != null && prevLineNumber == -1) {
lineCode = null; lineCode = null;

View File

@ -33,7 +33,7 @@ final class ChannelsReadSupport {
} }
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException { static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "news"; if (rootNumber == 0) return "0";
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1"; String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
@ -212,6 +212,111 @@ final class ChannelsReadSupport {
} }
} }
static int countViews(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = """
SELECT COUNT(*) AS cnt
FROM message_views_state
WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static boolean isSeenByLogin(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 1
FROM message_views_state
WHERE viewer_login = ? COLLATE NOCASE
AND to_bch_name = ?
AND to_block_number = ?
AND to_block_hash = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, toBch);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
static int countUnreadPosts(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return 0;
String sql = """
SELECT COUNT(*) AS cnt
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static PostBlock firstUnreadPost(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return null;
String sql = """
SELECT b.login,b.bch_name,b.block_number,b.block_hash,b.block_bytes
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
ORDER BY b.block_number ASC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
return pb;
}
}
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException { static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return ""; if (rootNumber == 0) return "";

View File

@ -108,11 +108,22 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
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)); item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash));
item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item); items.add(item);
} }
resp.setMessages(items); resp.setMessages(items);
int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode);
resp.setUnreadCount(unreadCount);
ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode);
if (firstUnread != null) {
Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef();
firstUnreadRef.setBlockNumber(firstUnread.blockNumber);
firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash));
resp.setFirstUnreadMessageRef(firstUnreadRef);
}
return resp; return resp;
} catch (Exception e) { } catch (Exception e) {
log.error("GetChannelMessages failed", e); log.error("GetChannelMessages failed", e);

View File

@ -178,6 +178,9 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
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)); node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash));
node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(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();
ci.setOwnerBlockchainName(row.bchName); ci.setOwnerBlockchainName(row.bchName);
@ -226,3 +229,4 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int msgSubType; int msgSubType;
} }
} }

View File

@ -74,7 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef); row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(0); row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) { if (lastPost != null) {

View File

@ -8,6 +8,8 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response { public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel; private Channel channel;
private List<MessageItem> messages = new ArrayList<>(); private List<MessageItem> messages = new ArrayList<>();
private int unreadCount;
private BlockRef firstUnreadMessageRef;
public Channel getChannel() { return channel; } public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; } public void setChannel(Channel channel) { this.channel = channel; }
@ -15,6 +17,11 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public List<MessageItem> getMessages() { return messages; } public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; } public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; }
public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; }
public static class Channel { public static class Channel {
private String ownerLogin; private String ownerLogin;
@ -48,6 +55,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private int likesCount; private int likesCount;
private boolean likedByMe; private boolean likedByMe;
private int repliesCount; private int repliesCount;
private int viewCount;
private boolean seenByMe;
private int versionsTotal; private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>(); private List<VersionItem> versions = new ArrayList<>();
@ -75,6 +84,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
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; }
public int getViewCount() { return viewCount; }
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
public boolean isSeenByMe() { return seenByMe; }
public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; }
public int getVersionsTotal() { return versionsTotal; } public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; } public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }

View File

@ -25,7 +25,6 @@ import java.util.regex.Pattern;
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$"); private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
private static final Pattern AVATAR_AR_TOKEN_PATTERN = Pattern.compile("(?:^|,)\\s*AR:([A-Za-z0-9_-]{43})\\s*(?:,|$)");
@Override @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@ -202,18 +201,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private String extractArAvatarTxId(String rawValue) { private String extractArAvatarTxId(String rawValue) {
String value = String.valueOf(rawValue == null ? "" : rawValue).trim(); String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
if (value.isEmpty()) return null; if (!value.startsWith("AR:")) return null;
var tokenMatch = AVATAR_AR_TOKEN_PATTERN.matcher(value); String txId = value.substring(3).trim();
String txId = tokenMatch.find() ? String.valueOf(tokenMatch.group(1)).trim() : "";
// fallback для старых/кривых значений без запятых: "SHA256:...AR:<txid>"
if (txId.isEmpty()) {
int idx = value.indexOf("AR:");
if (idx >= 0) {
int start = idx + 3;
int end = value.indexOf(',', start);
txId = (end >= 0 ? value.substring(start, end) : value.substring(start)).trim();
}
}
if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null; if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null;
return txId; return txId;
} }

View File

@ -51,18 +51,12 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
String turnUsername = ""; String turnUsername = "";
String turnPassword = ""; String turnPassword = "";
List<Net_GetCallIceConfig_Response.TurnServerConfig> turnServers = buildTurnServers(ctx, nowMs, ttlSec);
String sharedSecret = readStr("call.ice.turn.sharedSecret", ""); String sharedSecret = readStr("call.ice.turn.sharedSecret", "");
String staticUsername = readStr("call.ice.turn.username", ""); String staticUsername = readStr("call.ice.turn.username", "");
String staticPassword = readStr("call.ice.turn.password", ""); String staticPassword = readStr("call.ice.turn.password", "");
if (!turnServers.isEmpty()) { if (!turnUrls.isEmpty()) {
Net_GetCallIceConfig_Response.TurnServerConfig primary = turnServers.get(0);
turnUrls = primary.getUrls();
turnUsername = primary.getUsername();
turnPassword = primary.getPassword();
} else if (!turnUrls.isEmpty()) {
if (!sharedSecret.isBlank()) { if (!sharedSecret.isBlank()) {
long expiresEpochSec = nowMs / 1000L + ttlSec; long expiresEpochSec = nowMs / 1000L + ttlSec;
expiresAtMs = expiresEpochSec * 1000L; expiresAtMs = expiresEpochSec * 1000L;
@ -84,7 +78,6 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
resp.setTurnUrls(turnUrls); resp.setTurnUrls(turnUrls);
resp.setTurnUsername(turnUsername); resp.setTurnUsername(turnUsername);
resp.setTurnPassword(turnPassword); resp.setTurnPassword(turnPassword);
resp.setTurnServers(turnServers);
resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank()); resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank());
resp.setGeneratedAtMs(nowMs); resp.setGeneratedAtMs(nowMs);
resp.setExpiresAtMs(expiresAtMs); resp.setExpiresAtMs(expiresAtMs);
@ -92,40 +85,6 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
return resp; return resp;
} }
private static List<Net_GetCallIceConfig_Response.TurnServerConfig> buildTurnServers(ConnectionContext ctx, long nowMs, int ttlSec) {
List<Net_GetCallIceConfig_Response.TurnServerConfig> out = new ArrayList<>();
for (int i = 1; i <= 16; i++) {
String base = "call.ice.turn.servers." + i + ".";
List<String> urls = parseUrls(readStr(base + "urls", ""));
if (urls.isEmpty()) continue;
String id = readStr(base + "id", "turn-" + i);
String sharedSecret = readStr(base + "sharedSecret", "");
String staticUsername = readStr(base + "username", "");
String staticPassword = readStr(base + "password", "");
String username = "";
String password = "";
if (!sharedSecret.isBlank()) {
long expiresEpochSec = nowMs / 1000L + ttlSec;
String prefix = readStr("call.ice.turn.userPrefix", "shine");
String safeLogin = sanitizeLogin(ctx.getLogin());
username = expiresEpochSec + ":" + prefix + "_" + safeLogin;
password = makeTurnRestPassword(sharedSecret, username);
} else if (!staticUsername.isBlank() && !staticPassword.isBlank()) {
username = staticUsername;
password = staticPassword;
}
if (username.isBlank() || password.isBlank()) continue;
Net_GetCallIceConfig_Response.TurnServerConfig item = new Net_GetCallIceConfig_Response.TurnServerConfig();
item.setId(id);
item.setUrls(urls);
item.setUsername(username);
item.setPassword(password);
out.add(item);
}
return out;
}
private static int readInt(String key, int fallback) { private static int readInt(String key, int fallback) {
String value = CONFIG.getParam(key); String value = CONFIG.getParam(key);
if (value == null || value.isBlank()) return fallback; if (value == null || value.isBlank()) return fallback;
@ -187,3 +146,4 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
} }
} }
} }

View File

@ -6,27 +6,10 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public class Net_GetCallIceConfig_Response extends Net_Response { public class Net_GetCallIceConfig_Response extends Net_Response {
public static class TurnServerConfig {
private String id = "";
private List<String> urls = new ArrayList<>();
private String username = "";
private String password = "";
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
private List<String> stunUrls = new ArrayList<>(); private List<String> stunUrls = new ArrayList<>();
private List<String> turnUrls = new ArrayList<>(); private List<String> turnUrls = new ArrayList<>();
private String turnUsername = ""; private String turnUsername = "";
private String turnPassword = ""; private String turnPassword = "";
private List<TurnServerConfig> turnServers = new ArrayList<>();
private boolean turnEnabled; private boolean turnEnabled;
private long generatedAtMs; private long generatedAtMs;
private long expiresAtMs; private long expiresAtMs;
@ -44,9 +27,6 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
public String getTurnPassword() { return turnPassword; } public String getTurnPassword() { return turnPassword; }
public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; } public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; }
public List<TurnServerConfig> getTurnServers() { return turnServers; }
public void setTurnServers(List<TurnServerConfig> turnServers) { this.turnServers = turnServers; }
public boolean isTurnEnabled() { return turnEnabled; } public boolean isTurnEnabled() { return turnEnabled; }
public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; } public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; }
@ -59,3 +39,4 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
public int getTtlSec() { return ttlSec; } public int getTtlSec() { return ttlSec; }
public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; } public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; }
} }

View File

@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.messages;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
public class Net_AckIncomingMessage_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_AckIncomingMessage_Request req = (Net_AckIncomingMessage_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getEventId() != null && !req.getEventId().isBlank()) {
DeliveryTracker.getInstance().ack(req.getEventId());
}
Net_AckIncomingMessage_Response resp = new Net_AckIncomingMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
return resp;
}
}

View File

@ -9,14 +9,14 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Response; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender; import server.logic.ws_protocol.JSON.push.FcmPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.PushTokensDAO;
import shine.db.dao.SolanaUsersDAO; import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry; import shine.db.entities.PushTokenEntry;
import shine.db.entities.SolanaUserEntry; import shine.db.entities.SolanaUserEntry;
import java.util.HashSet; import java.util.HashSet;
@ -26,7 +26,6 @@ import java.util.Set;
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_INVITE = 100; private static final int TYPE_INVITE = 100;
private static final long PUSH_CALL_TTL_MS = 10_000L;
@Override @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@ -50,13 +49,12 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
String from = ctx.getLogin(); String from = ctx.getLogin();
String to = targetUser.getLogin(); String to = targetUser.getLogin();
long timeMs = System.currentTimeMillis(); long timeMs = System.currentTimeMillis();
long expiresAtMs = timeMs + PUSH_CALL_TTL_MS;
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to); Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
List<ActiveSessionEntry> allTargetSessions = ActiveSessionsDAO.getInstance().getByLogin(to); List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
int wsDelivered = 0; int wsDelivered = 0;
int webPushDelivered = 0; int fcmDelivered = 0;
Set<String> activeSessionIds = new HashSet<>(); Set<String> activeSessionIds = new HashSet<>();
for (ConnectionContext targetCtx : activeSessions) { for (ConnectionContext targetCtx : activeSessions) {
@ -76,31 +74,14 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
if (sent) wsDelivered++; if (sent) wsDelivered++;
} }
for (ActiveSessionEntry session : allTargetSessions) { for (PushTokenEntry token : tokens) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim(); boolean pushed = FcmPushSender.sendNotification(
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) { token.getToken(),
continue; "Входящий звонок",
} from + " пытается дозвониться",
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { callId
continue;
}
String payload = "{\"kind\":\"incoming_call\""
+ ",\"title\":\"SHiNE: входящий звонок\""
+ ",\"text\":\"Вам звонит " + jsonEscape(from) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(from) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(ctx.getSessionId()) + "\""
+ ",\"toLogin\":\"" + jsonEscape(to) + "\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"sentAtMs\":" + timeMs
+ ",\"expiresAtMs\":" + expiresAtMs
+ "}";
boolean pushed = WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
payload
); );
if (pushed) webPushDelivered++; if (pushed) fcmDelivered++;
} }
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response(); Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
@ -109,27 +90,7 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setCallId(callId); resp.setCallId(callId);
resp.setDeliveredWsSessions(wsDelivered); resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(webPushDelivered); resp.setDeliveredFcmSessions(fcmDelivered);
resp.setDeliveredWebPushSessions(webPushDelivered);
return resp; return resp;
} }
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String jsonEscape(String s) {
if (s == null) return "";
StringBuilder out = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') out.append("\\\\");
else if (c == '"') out.append("\\\"");
else if (c == '\n') out.append("\\n");
else if (c == '\r') out.append("\\r");
else if (c == '\t') out.append("\\t");
else out.append(c);
}
return out.toString();
}
} }

View File

@ -9,25 +9,18 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Response; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO; import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry; import shine.db.entities.SolanaUserEntry;
import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
public class Net_CallSignalToSession_Handler implements JsonMessageHandler { public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_ACCEPT = 120; private static final int TYPE_ACCEPT = 120;
private static final int TYPE_DECLINE_BUSY = 130;
private static final int TYPE_TIMEOUT = 140;
private static final int TYPE_HANGUP = 150; private static final int TYPE_HANGUP = 150;
@Override @Override
@ -79,34 +72,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload); boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
if (type == TYPE_ACCEPT) { if (type == TYPE_ACCEPT) {
notifyStopOnOtherSessions( notifyAcceptedOnOtherSessions(ctx, callId);
ctx.getLogin(),
ctx.getSessionId(),
ctx.getLogin(),
ctx.getSessionId(),
callId,
"accepted_on_other_device"
);
}
if (type == TYPE_DECLINE_BUSY || type == TYPE_TIMEOUT || type == TYPE_HANGUP) {
String reason = "terminal_call_signal_" + type;
notifyStopOnOtherSessions(
ctx.getLogin(),
ctx.getSessionId(),
ctx.getLogin(),
ctx.getSessionId(),
callId,
reason
);
notifyStopOnOtherSessions(
to,
targetCtx.getSessionId(),
ctx.getLogin(),
ctx.getSessionId(),
callId,
reason
);
} }
Net_CallSignalToSession_Response resp = new Net_CallSignalToSession_Response(); Net_CallSignalToSession_Response resp = new Net_CallSignalToSession_Response();
@ -117,81 +83,31 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
return resp; return resp;
} }
private void notifyStopOnOtherSessions( private void notifyAcceptedOnOtherSessions(ConnectionContext accepterCtx, String callId) {
String targetLogin, if (accepterCtx == null) return;
String excludeSessionId, String login = accepterCtx.getLogin();
String fromLogin, String acceptedSessionId = accepterCtx.getSessionId();
String fromSessionId, if (login == null || login.isBlank() || acceptedSessionId == null || acceptedSessionId.isBlank() || callId == null || callId.isBlank()) {
String callId,
String reason
) throws Exception {
if (isBlank(targetLogin) || isBlank(callId)) {
return; return;
} }
Set<String> onlineSessionIds = new HashSet<>(); Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(login);
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
for (ConnectionContext siblingCtx : sameUserSessions) { for (ConnectionContext siblingCtx : sameUserSessions) {
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue; if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
onlineSessionIds.add(String.valueOf(siblingCtx.getSessionId() == null ? "" : siblingCtx.getSessionId()).trim()); if (acceptedSessionId.equals(siblingCtx.getSessionId())) continue;
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
String siblingEventId = NetIdGenerator.eventId("evt"); String siblingEventId = NetIdGenerator.eventId("evt");
ObjectNode siblingPayload = MAPPER.createObjectNode(); ObjectNode siblingPayload = MAPPER.createObjectNode();
siblingPayload.put("eventId", siblingEventId); siblingPayload.put("eventId", siblingEventId);
siblingPayload.put("fromLogin", fromLogin); siblingPayload.put("fromLogin", login);
siblingPayload.put("fromSessionId", fromSessionId); siblingPayload.put("fromSessionId", acceptedSessionId);
siblingPayload.put("toLogin", targetLogin); siblingPayload.put("toLogin", login);
siblingPayload.put("callId", callId); siblingPayload.put("callId", callId);
siblingPayload.put("type", TYPE_HANGUP); siblingPayload.put("type", TYPE_HANGUP);
siblingPayload.put("data", reason); siblingPayload.put("data", "accepted_on_other_device");
siblingPayload.put("timeMs", System.currentTimeMillis()); siblingPayload.put("timeMs", System.currentTimeMillis());
WsEventSender.sendEvent(siblingCtx, "IncomingCallSignal", siblingEventId, siblingPayload); WsEventSender.sendEvent(siblingCtx, "IncomingCallSignal", siblingEventId, siblingPayload);
} }
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
long sentAtMs = System.currentTimeMillis();
for (ActiveSessionEntry session : persistedSessions) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue;
if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue;
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
continue;
}
String pushPayload = "{\"kind\":\"stop_call\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ "}";
WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
pushPayload
);
}
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String jsonEscape(String s) {
if (s == null) return "";
StringBuilder out = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') out.append("\\\\");
else if (c == '"') out.append("\\\"");
else if (c == '\n') out.append("\\n");
else if (c == '\r') out.append("\\r");
else if (c == '\t') out.append("\\t");
else out.append(c);
}
return out.toString();
} }
} }

View File

@ -0,0 +1,13 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_AckIncomingMessage_Request extends Net_Request {
private String eventId;
private String messageId;
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
}

View File

@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_AckIncomingMessage_Response extends Net_Response {
}

View File

@ -6,7 +6,6 @@ public class Net_CallInviteBroadcast_Response extends Net_Response {
private String callId; private String callId;
private int deliveredWsSessions; private int deliveredWsSessions;
private int deliveredFcmSessions; private int deliveredFcmSessions;
private int deliveredWebPushSessions;
public String getCallId() { return callId; } public String getCallId() { return callId; }
public void setCallId(String callId) { this.callId = callId; } public void setCallId(String callId) { this.callId = callId; }
@ -16,7 +15,4 @@ public class Net_CallInviteBroadcast_Response extends Net_Response {
public int getDeliveredFcmSessions() { return deliveredFcmSessions; } public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; } public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
} }

View File

@ -9,18 +9,16 @@ import java.util.jar.JarFile;
public class IT_DeployRestartNoCleanNoTestsMain { public class IT_DeployRestartNoCleanNoTestsMain {
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ====== // ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me"); private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player"); private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server"); private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server"); private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar"); private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
public static void main(String[] args) { public static void main(String[] args) {
ensureSudoNoPasswordOrThrow();
// 1) stop service на сервере // 1) stop service на сервере
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true"); sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
@ -42,15 +40,6 @@ public class IT_DeployRestartNoCleanNoTestsMain {
System.out.println("deploy_no_clean_no_tests_done"); System.out.println("deploy_no_clean_no_tests_done");
} }
private static void ensureSudoNoPasswordOrThrow() {
int code = ssh("sudo -n systemctl status " + SERVICE_NAME + " >/dev/null 2>&1");
if (code == 0) return;
throw new RuntimeException(
"Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST
+ ". Configure NOPASSWD for 'systemctl ... " + SERVICE_NAME + "' or run deploy with privileged user."
);
}
private static void waitRemotePort7070() { private static void waitRemotePort7070() {
for (int i = 0; i < 50; i++) { for (int i = 0; i < 50; i++) {
int code = ssh("ss -ltnp | grep -q ':7070'"); int code = ssh("ss -ltnp | grep -q ':7070'");

View File

@ -1,170 +0,0 @@
# Как устроены каналы в блокчейне SHiNE
## 1) Коротко: что такое “канал” в текущей реализации
В SHiNE канал — это не отдельная таблица сообщений, а **линия блоков** внутри блокчейна пользователя:
- канал создается TECH-блоком `CreateChannelBody` (`msg_type=0`, `msg_sub_type=1`);
- сообщения канала — это `TEXT_POST` (`msg_type=1`, `msg_sub_type=10`) с `line_code = rootBlockNumber канала`;
- ответы (треды) — это `TEXT_REPLY` (`msg_sub_type=20`) с target-ссылкой на конкретный блок-сообщение.
То есть канал = “корневой блок канала” + все посты в его линии + связанные ответы.
---
## 2) Как канал появляется
Создание канала идет через `AddBlock`:
1. UI собирает `CreateChannelBody v2`.
2. UI подписывает блок приватным blockchain-ключом на устройстве.
3. UI отправляет на сервер `AddBlock` с `blockBytesB64` (полный бинарный блок: preimage + sigMarker + signature).
4. Сервер:
- проверяет цепочку (`prevHash`, `blockNumber=last+1`);
- парсит body;
- валидирует подпись;
- валидирует имя канала;
- сохраняет блок и обновляет state.
Дополнительно сервер поддерживает `channel_names_state` как нормализованное состояние названий каналов.
---
## 3) Правила имени и описания канала
### Имя канала (`ChannelNameRules`)
- длина: `3..32` символов (code points);
- допустимые символы: только латиница `A-Z a-z`, цифры `0-9`, `_`, `-`;
- имя нормализуется как `trim`;
- канонический slug: lower-case того же имени (без дополнительных преобразований).
### Уникальность
Проверяется по slug. При конфликте сервер возвращает `channel_name_already_exists`.
### Описание канала
В `CreateChannelBody v2` описание хранится прямо в блоке (до 200 байт UTF-8).
После создания описание в текущей реализации не редактируется (отдельного механизма обновления пока нет).
---
## 4) Канал “news” (root 0)
`rootBlockNumber=0` — системный канал `news` (новостной канал по умолчанию).
Публикации `TEXT_POST` в `news` сейчас отключены (на сервере есть явный запрет записи в root 0).
---
## 5) Как идут сообщения в канале
### Публикация поста
UI вызывает `addBlockTextPost` -> `AddBlock` с `TEXT_POST`.
Ограничения:
- писать можно только в **свой** блокчейн и свои каналы;
- для поста задается `line_code` канала;
- пост в канале — это новый неизменяемый блок.
### Ответы
Ответы (`TEXT_REPLY`) не обязаны лежать в той же линии.
Они ссылаются на целевой блок через target (`to_bch_name`, `to_block_number`, `to_block_hash`).
Это позволяет отвечать и из других блокчейнов (межпользовательский тред).
---
## 6) Редактирование и удаление сообщений
### Редактирование
Поддержано на уровне протокола:
- `TEXT_EDIT_POST` (11) — правка поста;
- `TEXT_EDIT_REPLY` (21) — правка ответа.
Правка — это **новый блок**, ссылающийся на оригинал.
Оригинальный блок не меняется.
Серверные read-API уже собирают `versions[]` и `versionsTotal`.
### Удаление
Сейчас отдельного subtype “delete post/reply” нет.
Физического удаления блоков из цепочки нет (блоки иммутабельны).
Итог:
- изменить можно через edit-блок;
- удалить “как в чате” сейчас нельзя.
---
## 7) Как UI получает канал
Основные read-API:
- `ListSubscriptionsFeed` — список каналов/подписок;
- `GetChannelMessages` — посты канала;
- `GetMessageThread` — тред вокруг выбранного сообщения.
Важно: UI получает **JSON-представление**, собранное сервером из блоков БД, а не сырые блоки по умолчанию.
В JSON возвращаются:
- `messageRef` (номер и hash блока),
- автор,
- текущий текст,
- `versions[]` (оригинал + правки),
- counters (`likesCount`, `repliesCount`).
---
## 8) Как строится тред
`GetMessageThread`:
1. Находит focus-сообщение по `(blockchainName, blockNumber)`.
2. Строит `ancestors` вверх по target-ссылкам.
3. Строит `descendants` вниз: replies, где target = focus.
Запросы в БД идут по `to_bch_name + to_block_number + to_block_hash`, поэтому ответы из других блокчейнов тоже связываются.
---
## 9) Что проверяется криптографически
При записи (`AddBlock`) сервер проверяет:
- корректность формата блока;
- непрерывность цепочки;
- `SHA-256(preimage)` и Ed25519-подпись;
- соответствие публичного blockchain-ключа пользователя.
На чтении (`GetChannelMessages`, `GetMessageThread`) сервер отдает уже сохраненные данные (JSON из БД).
Повторная верификация каждой записи при каждом чтении не делается.
---
## 10) UI-статус на сегодня (важно)
На момент этого документа:
- в UI нет полноценного экрана истории правок сообщения (хотя `versions` уже приходят);
- нет операции удаления сообщений (и на протоколе нет delete subtype);
- канал читается как JSON-слой поверх блоков, а не как “сырой бинарный блок-объект”.
---
## 11) Практический вывод по модели данных
Каналы в SHiNE — это append-only модель:
- каждое действие = новый подписанный блок;
- “изменение” = добавление новой версии, не перезапись старой;
- целостность и авторство обеспечиваются подписью и связностью цепочки;
- UI может показывать удобный “чатовый” вид, но источник истины — блоки.

View File

@ -16,23 +16,6 @@
5. Если пара реально добавилась в БД, сервер запускает realtime-доставку в активные сессии целевых пользователей. 5. Если пара реально добавилась в БД, сервер запускает realtime-доставку в активные сессии целевых пользователей.
6. Если это дубль, дальнейшая доставка не выполняется (повтор не разгоняется). 6. Если это дубль, дальнейшая доставка не выполняется (повтор не разгоняется).
## Как сообщение доходит до клиента (WS + WebPush)
1. После успешной записи сервер пытается доставить сообщение во все активные сессии `targetLogin`.
2. Для каждой сессии сервер сначала создаёт/проверяет запись доставки в `signed_message_session_delivery` (pending).
3. Если сессия онлайн, сервер шлёт `SignedMessageArrived` по WebSocket.
4. Если сессия офлайн и тип сообщения входящий текст (`TYPE_INCOMING_TEXT`), сервер пробует WebPush (если у сессии сохранены `endpoint/p256dh/auth`).
5. При следующем логине/переподключении сервер дочитывает pending-сообщения и повторно отправляет их в эту сессию как backlog.
## Подтверждение доставки (ACK)
- Используется метод `AckSessionDelivery`.
- Клиент отправляет ACK после обработки `SignedMessageArrived` с `messageKey`.
- Сервер помечает `(messageKey, sessionId)` как `delivered=1`, и это сообщение перестаёт быть pending для этой сессии.
### Важно про безопасность ACK
- `AckSessionDelivery` требует авторизованную WS-сессию (`ctx.isAuthenticatedUser()`).
- `sessionId` берётся сервером из текущего `ConnectionContext`, а не из payload запроса.
- Поэтому подтвердить доставку «просто зная messageKey/sessionId» без авторизованной сессии нельзя.
## Почему допускаются дубли сети ## Почему допускаются дубли сети
- В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения. - В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения.
- Дедупликация делается на уровне БД по ключам записи. - Дедупликация делается на уровне БД по ключам записи.

View File

@ -1,44 +0,0 @@
# Логика установки звонка через сервер (актуальная)
## 1) Ключевые API
- `CallInviteBroadcast` — широковещательный старт входящего звонка (`type=100`) по пользователю.
- `CallSignalToSession` — точечный сигнал в конкретную сессию (`RINGING/ACCEPT/DECLINE/TIMEOUT/HANGUP/OFFER/ANSWER/ICE`).
## 2) Базовый поток звонка
1. Инициатор отправляет `CallInviteBroadcast(toLogin, callId, type=100)`.
2. Сервер отправляет онлайн-сессиям callee событие `IncomingCallInvite` по WS.
3. Офлайн-сессиям callee сервер отправляет WebPush `incoming_call` (с TTL).
4. Любая сессия callee, получив invite, может отправить `RINGING`.
5. Инициатор после первого `RINGING` показывает «Вызываем…».
6. При `ACCEPT` выбирается одна целевая сессия, остальные получают `HANGUP/stop_call` и закрывают экран входящего.
7. Далее только выбранная пара сессий обменивается `OFFER/ANSWER/ICE`.
## 3) WebPush по звонкам
### `incoming_call`
- Используется как fallback для офлайн-сессий.
- Поля: `kind`, `callId`, `fromLogin`, `fromSessionId`, `toLogin`, `sentAtMs`, `expiresAtMs`.
- TTL сейчас: **10 секунд**.
- Если push пришёл после `expiresAtMs`, уведомление не показывается.
### `stop_call`
- Отправляется при завершении/отмене/принятии на другом устройстве.
- Поля: `kind`, `callId`, `reason`, `fromLogin`, `fromSessionId`, `toLogin`, `sentAtMs`.
- Service Worker закрывает уведомление по `tag=callId`.
## 4) Кнопки в push-уведомлении
- Для `incoming_call` есть actions:
- `accept` — «Ответить»
- `decline` — «Сбросить»
- По нажатию action:
- Service Worker шлёт событие в открытые вкладки,
- и открывает/фокусит UI с параметрами действия.
- UI пытается выполнить действие сразу (принять/отклонить), если сессия уже авторизована.
## 5) Важные ограничения
- Источник истины по звонку — серверный сигналинг (WS + серверные сигналы), push только вспомогательный канал.
- Push может прийти с задержкой и не по порядку, поэтому клиент фильтрует устаревшие события (`expiresAtMs` + `callId` + `stop_call`).
- Полноценный «автоответ в фоне без открытия UI» в web/PWA не гарантируется платформой.
## 6) Ограничение текущей архитектуры
- Сейчас звонки надёжно работают в рамках одного сигнального контура (когда обе стороны подключены к совместимому серверу/кластеру).
- Межсерверный сценарий «A и B на полностью разных серверах» пока не завершён и вынесен в TODO.

View File

@ -1,141 +0,0 @@
# Типы блоков и сообщений SHiNE (карта протокола)
## 1) Главный принцип
В блокчейн попадают только записи `AddBlock` (подписанные бинарные блоки).
Все остальное (например, call signaling, push-события, служебные JSON-операции) — не блокчейн-данные.
---
## 2) Базовые `msg_type`
## `msg_type=0` — TECH
- `subType=0``HEADER_COMPAT` (техническая совместимость);
- `subType=1``TECH_CREATE_CHANNEL` (создание канала).
Используется для структуры каналов.
## `msg_type=1` — TEXT
- `10``TEXT_POST` (пост в линии канала);
- `11``TEXT_EDIT_POST` (правка поста);
- `20``TEXT_REPLY` (ответ на сообщение через target);
- `21``TEXT_EDIT_REPLY` (правка ответа).
Это основной контент каналов и тредов.
## `msg_type=2` — REACTION
- `1``REACTION_LIKE`;
- `2``REACTION_UNLIKE`.
Лайки/снятие лайка, считаются через state-триггеры и/или агрегации.
## `msg_type=3` — CONNECTION
Связи между пользователями (friend/contact/follow/spouse/parent/child/sibling + обратные UN*).
Используется для соцграфа и подписок:
- `FOLLOW/UNFOLLOW` — подписки на авторов/каналы.
## `msg_type=4` — USER_PARAM
Ключ-значение параметра пользователя (profile / тех.параметры).
Для описаний каналов `USER_PARAM` больше не используется: описание хранится только в `CreateChannelBody v2`.
---
## 3) Что **не** является блокчейн-типом
Ниже операции есть в протоколе, но не через `AddBlock`:
- `CallInviteBroadcast`, `CallSignalToSession` (сигналинг звонков),
- `SendDirectMessage`, `ReceiveIncomingMessage`, `ReceiveOutcomingMessage`,
- `AckSessionDelivery`, `UpsertPushToken`, `SendTestWebPush`,
- системные `Ping`, `GetServerInfo`, логи и т.п.
Это JSON-операции поверх WS/серверной логики.
---
## 4) Формат блока (высокоуровнево)
Блок включает:
1. preimage (header + body),
2. `sigMarker`,
3. `signature64`.
`hash32 = SHA-256(preimage)`, подпись Ed25519 проверяется сервером при `AddBlock`.
Ключевые проверки на сервере:
- `blockNumber == last + 1`,
- `prevHash` совпадает с последним хэшем цепочки,
- body валиден по типу/версии/subtype,
- подпись корректна.
---
## 5) Где и как это используется в UI
## Уже активно
- создание канала (`TECH_CREATE_CHANNEL`);
- пост в канал (`TEXT_POST`);
- ответ (`TEXT_REPLY`);
- лайк/анлайк (`REACTION_*`);
- follow/unfollow через connection-блоки.
## Частично готово на API, но не доведено в UI
- отображение полной истории правок (`versions[]` есть в API, но UI показывает не полностью как отдельный workflow);
- редактирование поста/ответа (типы в протоколе есть, UI-сценарий не завершен);
- удаление сообщений отсутствует как тип.
---
## 6) Про “AI сообщения”
Отдельного `msg_type/subType` “AI message” в текущем протоколе нет.
Если нужно, это обычно делают либо:
- как новый `TEXT_*` subtype (если это контент канала),
- либо как отдельный новый `msg_type` (если нужна независимая семантика/правила).
---
## 7) Почему в UI виден JSON, а не “сырой блок”
Текущий read-path сделан так:
- сервер читает блоки из БД;
- парсит и собирает удобное JSON-представление;
- UI рендерит его как сообщения/треды.
Плюсы:
- проще и быстрее для интерфейса;
- не дублируется сложная логика парсинга блоков на клиенте.
Минусы:
- клиент не делает локальную крипто-верификацию каждого прочитанного элемента.
При необходимости можно добавить режим “raw block view” и верификацию на клиенте как отдельный экспертный экран.
---
## 8) Рекомендация по UX/протоколу
Для обычного пользователя лучше оставить “UI-сообщения” (читаемо и быстро).
Для аудита/доверия имеет смысл добавить отдельный режим:
- показать `blockNumber/hash/signature`,
- показать все версии,
- кнопка “проверить подпись локально” (advanced).
Так получится и удобство, и проверяемость.