Compare commits

...

10 Commits

53 changed files with 1905 additions and 992 deletions

View File

@ -14,3 +14,29 @@
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
- Базовое правило инкремента: `+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

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

View File

@ -0,0 +1,41 @@
# 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.35
server.version=1.2.29
client.version=1.2.44
server.version=1.2.38

View File

@ -0,0 +1,105 @@
Дата: 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-блоков.

24
predeploy/servers.md Normal file
View File

@ -0,0 +1,24 @@
# 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

@ -0,0 +1,17 @@
# 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,5 +1,7 @@
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.__shineStoppedCalls = self.__shineStoppedCalls || new Map();
self.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type === 'SKIP_WAITING') {
@ -17,6 +19,61 @@ 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) => {
let body = '';
let rawText = '';
@ -27,13 +84,12 @@ self.addEventListener('push', (event) => {
if (event.data) {
const text = event.data.text();
rawText = text || '';
try {
const json = JSON.parse(rawText || '{}');
kind = String(json.kind || '');
title = String(json.title || '');
body = String(json.text || '');
fromLogin = String(json.fromLogin || '');
} catch {
const json = decodePushJson(rawText);
kind = String(json.kind || '');
title = String(json.title || '');
body = String(json.text || '');
fromLogin = String(json.fromLogin || '');
if (!kind && rawText) {
body = rawText || '';
}
}
@ -41,34 +97,106 @@ self.addEventListener('push', (event) => {
// ignore
}
const shouldNotify = kind === 'new_message' || kind === 'test_push' || (!kind && body);
const json = decodePushJson(rawText);
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'
? (title || 'SHiNE: тестовый push')
: 'SHiNE: входящее сообщение';
: (kind === 'incoming_call'
? 'SHiNE: входящий звонок'
: 'SHiNE: входящее сообщение');
const notifyPromise = shouldNotify
? self.registration.showNotification(notificationTitle, {
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
tag: kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message',
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
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();
const closeOnStopPromise = kind === 'stop_call' && callId
? closeCallNotification(callId)
: Promise.resolve();
event.waitUntil(Promise.all([
notifyPromise,
closeOnStopPromise,
broadcastToClients({
kind,
body,
fromLogin,
fromSessionId,
toLogin,
callId,
sentAtMs,
expiresAtMs,
reason,
stale: isExpiredIncomingCall || isIncomingCallAlreadyStopped,
rawText,
receivedAt: Date.now(),
receivedAt: nowMs,
}),
]));
});
self.addEventListener('notificationclick', (event) => {
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 () => {
if ((action === 'accept' || action === 'decline') && payload.callId) {
await broadcastCallActionToClients(action, payload);
}
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const existing = allClients.find((client) => {
try {
@ -78,11 +206,26 @@ 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) {
try {
if (action === 'accept' || action === 'decline') {
existing.postMessage({
type: 'SHINE_CALL_PUSH_ACTION',
action,
payload,
});
}
} catch {}
await existing.focus();
return;
}
await self.clients.openWindow('./index.html');
await self.clients.openWindow(openUrl);
})());
});

View File

@ -5,8 +5,11 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
import {
handleCallPushAction,
handleIncomingCallInvite,
handleIncomingCallPush,
handleIncomingCallSignal,
handleStopCallPush,
setCallDebugReporter,
startDebugConnectionAsInitiator,
startDebugConnectionAsResponder,
@ -127,6 +130,7 @@ let uiUpdateReloadScheduled = false;
let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
@ -220,6 +224,85 @@ function startConnectionCountdown() {
}, 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 = '') {
const state = String(nextState || '').trim();
if (!state) return;
@ -677,9 +760,13 @@ async function ensureSessionRuntimeStarted() {
});
}
}, 15_000);
await processPendingCallPushActionIfPossible();
}
async function init() {
consumeCallPushActionFromUrlIfAny();
addAppLogEntry({
level: 'info',
source: 'app',
@ -698,12 +785,23 @@ async function init() {
setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted();
void processPendingCallPushActionIfPossible();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
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;
const payload = data.payload || {};
const kind = String(payload.kind || '').trim();
const now = Date.now();
@ -723,6 +821,11 @@ async function init() {
message: 'Получено push-событие в service worker',
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 }));
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -21,9 +21,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
const seenFlushTimersByRoute = new Map();
const seenPendingByRoute = new Map();
const firstUnreadJumpByRoute = new Map();
function isChannelsDemoMode() {
try {
@ -126,44 +123,16 @@ function buildAbsoluteRouteUrl(routePath = '') {
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) {
const params = route?.params || {};
if (params.ownerLogin && params.channelName) {
return {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
};
}
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
@ -186,6 +155,17 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) {
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 [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
@ -395,88 +375,6 @@ function openAddMessageModal({ channelName, onSubmit }) {
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) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
@ -502,8 +400,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
viewCount: Number(message?.viewCount || 0),
seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
@ -511,7 +407,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
}
async function loadFromApi(route, channelId) {
const selector = buildSelectorFromRoute(route, channelId);
let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerLogin && selector?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
if (!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) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
@ -520,37 +434,15 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
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 {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: resolvedDescription,
description: String(payload.channel?.channelDescription || '').trim(),
ownerName: ownerLogin || 'неизвестно',
},
posts,
unreadCount: Number.isFinite(unreadFromPayload)
? Math.max(0, unreadFromPayload)
: posts.filter((post) => post.seenByMe !== true).length,
firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
};
@ -665,11 +557,7 @@ function renderPostCard(post, {
body.className = 'channel-message-body';
body.textContent = post.body;
const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(topRow, body, views);
card.append(topRow, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@ -703,19 +591,23 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Сияние...';
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@ -724,7 +616,7 @@ function renderPostCard(post, {
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<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) => {
animatePress(event.currentTarget);
@ -754,7 +646,7 @@ function renderPostCard(post, {
shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = `
<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) => {
event.stopPropagation();
@ -793,22 +685,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
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(owner, headActions);
@ -816,25 +692,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
const feed = document.createElement('div');
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 unreadKeys = new Set();
let seenFlushInFlight = false;
let seenObserver = null;
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
@ -849,7 +711,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
@ -860,101 +721,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
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) {
actionButton.addEventListener('click', (event) => {
@ -965,7 +731,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
});
} else {
actionButton.addEventListener('click', handlers.onUnfollowChannel);
actionButton.addEventListener('click', handlers.onSubscribeChannel);
}
const backButton = document.createElement('button');
@ -973,57 +739,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
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);
}
screen.append(head, actionButton, feed, backButton);
applyPendingScroll(screen, routeKey);
return () => {
seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
// noop
};
}
@ -1070,7 +790,9 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
throw new Error('Сессия недействительна. Выполните вход заново.');
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
@ -1117,21 +839,6 @@ export function render({ navigate, route }) {
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) => {
try {
const routeToShare = String(routePath || '').trim();
@ -1145,7 +852,7 @@ export function render({ navigate, route }) {
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
@ -1168,25 +875,6 @@ export function render({ navigate, route }) {
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(
renderHeader({
title: '',
@ -1229,19 +917,14 @@ export function render({ navigate, route }) {
}
},
onShare: onShare,
onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {
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({
login,
@ -1249,26 +932,15 @@ export function render({ navigate, route }) {
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
unfollow: false,
});
softHaptic(15);
showToast('Отписка от канала выполнена');
navigate('channels-list');
showToast('Подписка на канал выполнена');
} 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) {
skeleton.remove();

View File

@ -40,6 +40,11 @@ function normalizeLoginInput(value) {
}
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 rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
@ -406,6 +411,117 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
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() {
const mapRow = (channel) => ({
...channel,
@ -527,6 +643,16 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div');
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;
}
@ -896,14 +1022,9 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
}
if (tab === 'authors') {
button.textContent = 'Подписаться на автора';
button.textContent = '🔍 Поиск каналов';
button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на автора',
submitLabel: 'Подписаться',
onSuccess: onReload,
});
button.onclick = () => openChannelFinderModal({ navigate });
return;
}
@ -954,7 +1075,7 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState();
const listState = {
activeTab: 'my',
activeTab: 'subscriptions',
openMenuId: null,
notificationsState,
revealedCounters: new Set(),
@ -1001,8 +1122,8 @@ export function render({ navigate }) {
};
const tabItems = [
{ key: 'subscriptions', label: 'Каналы' },
{ key: 'my', label: 'Мои' },
{ key: 'subscriptions', label: 'Подписки' },
{ key: 'authors', label: 'Авторы' },
];

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export function render({ navigate }) {
solanaServer: state.entrySettings.solanaServer,
shineServer: state.entrySettings.shineServer,
arweaveServer: state.entrySettings.arweaveServer,
callPreflightTimeoutMs: Number(state.entrySettings.callPreflightTimeoutMs || 6000),
statuses: { ...state.entrySettings.statuses },
};
@ -108,6 +109,33 @@ export function render({ navigate }) {
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');
actions.className = 'auth-footer-actions';

View File

@ -50,6 +50,42 @@ export function getRoute() {
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') {
return {
pageId,

View File

@ -1,4 +1,9 @@
import { buildArweaveDataUrl, validateArweaveTxId } from './arweave-file-service.js';
import {
buildArweaveDataUrl,
sha256HexFromArrayBuffer,
validateArweaveTxId,
validateSha256Hex,
} from './arweave-file-service.js';
const DB_NAME = 'shine-ui-avatar-cache';
const DB_VERSION = 1;
@ -55,6 +60,14 @@ 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() {
return withStore('readonly', (store, _tx, resolve, reject) => {
const req = store.getAll();
@ -146,22 +159,41 @@ function detectImageMime(bytes) {
return '';
}
async function getBlobFromCacheOrGateway({ gateway, txId }) {
async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = '' }) {
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
try {
const cached = await getRecord(txId);
if (cached?.blob instanceof Blob) {
return cached.blob;
if (!validateSha256Hex(expected)) 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 {
// ignore IndexedDB errors and fallback to fetch
}
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 = {
txId,
blob,
contentType: String(blob.type || 'application/octet-stream'),
sizeBytes: Number(blob.size || 0),
sha256Hex: computedSha256Hex,
cachedAtMs: Date.now(),
};
try {
@ -173,12 +205,16 @@ async function getBlobFromCacheOrGateway({ gateway, txId }) {
return blob;
}
export async function getCachedAvatarObjectUrl({ gateway, txId }) {
export async function getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex = '' }) {
const cleanTxId = String(txId || '').trim();
if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave');
}
const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
const blob = await getBlobFromCacheOrGateway({
gateway,
txId: cleanTxId,
expectedSha256Hex,
});
return URL.createObjectURL(blob);
}

View File

@ -4,6 +4,7 @@ const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
const SHA256_HEX_RE = /^[A-Fa-f0-9]{64}$/;
let arweaveLibPromise = null;
@ -92,29 +93,64 @@ function canvasToBlob(canvas, type, quality) {
});
}
function bytesToHex(bytes) {
return Array.from(bytes, (item) => item.toString(16).padStart(2, '0')).join('');
}
export function validateArweaveTxId(txId) {
const value = String(txId || '').trim();
return TX_ID_RE.test(value);
}
export function buildArweaveAvatarValue(txId) {
export function validateSha256Hex(sha256Hex) {
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();
if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave');
}
return `AR:${cleanTxId}`;
const cleanSha = String(sha256Hex || '').trim().toLowerCase();
if (!cleanSha) return `AR:${cleanTxId}`;
if (!validateSha256Hex(cleanSha)) {
throw new Error('Некорректный SHA256 хэш аватара');
}
return `SHA256:${cleanSha},AR:${cleanTxId}`;
}
export function parseArweaveAvatarValue(value) {
const raw = String(value || '').trim();
if (!raw.startsWith('AR:')) {
return { ok: false, network: '', txId: '' };
if (!raw) {
return { ok: false, network: '', txId: '', sha256Hex: '' };
}
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)) {
return { ok: false, network: '', txId: '' };
return { ok: false, network: '', txId: '', sha256Hex: '' };
}
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 }) {
@ -209,6 +245,8 @@ export async function prepareAvatarImageFile(file) {
}
const optimizedFile = blobToFile(blob, fileName, contentType);
const optimizedArrayBuffer = await optimizedFile.arrayBuffer();
const sha256Hex = await sha256HexFromArrayBuffer(optimizedArrayBuffer);
return {
file: optimizedFile,
originalSizeBytes: Number(file.size || 0),
@ -218,6 +256,7 @@ export async function prepareAvatarImageFile(file) {
width,
height,
contentType,
sha256Hex,
};
} catch (error) {
if (error instanceof Error) throw error;

View File

@ -92,27 +92,6 @@ function opError(op, response) {
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() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
@ -416,26 +395,6 @@ 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) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const bytes = utf8Bytes(text);
@ -740,7 +699,12 @@ export class AuthService {
}
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
const payload = { channel, limit, sort };
const normalizedChannel = {
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();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload);
@ -749,7 +713,12 @@ export class AuthService {
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const payload = { message, depthUp, depthDown, limitChildrenPerNode };
const normalizedMessage = {
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();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload);
@ -757,17 +726,6 @@ export class AuthService {
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 }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
@ -1089,44 +1047,21 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
const submitCreate = async (useV2) => {
const bodyBytes = useV2
? makeCreateChannelBodyV2Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
})
: makeCreateChannelBodyBytes({
lineCode: 0,
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 payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyV2Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
}),
});
const selector = {
ownerBlockchainName: blockchainName,
@ -1134,24 +1069,8 @@ export class AuthService {
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 {
...payload,
usedLegacyDescriptionFallback,
savedDescriptionViaUserParam,
channel: {
...selector,
},

View File

@ -1,4 +1,4 @@
import { addSystemChatMessage, authService } from '../state.js';
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js';
const TYPES = {
INVITE: 100,
@ -31,6 +31,47 @@ function nowMs() {
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() {
return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`;
}
@ -853,8 +894,23 @@ async function finalizeCall(call, {
call.phase = 'ended';
call.statusText = 'Звонок завершён';
if (String(localReasonCode || '') === 'busy') {
call.statusText = 'Пользователь занят';
}
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);
if (activeCallId === call.callId) {
activeCallId = '';
@ -1064,6 +1120,84 @@ function ensureIncomingNotification(peerLogin) {
} 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) {
debugReporter = typeof fn === 'function' ? fn : null;
}
@ -1221,6 +1355,12 @@ export async function startOutgoingCall(peerLogin) {
}
const callId = makeCallId();
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
if (!preflightOk) {
throw new Error('Сервер временно недоступен');
}
const call = {
callId,
peerLogin: cleanPeer,
@ -1266,75 +1406,26 @@ export async function startOutgoingCall(peerLogin) {
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
} 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)}` });
throw error;
}
}
export async function handleIncomingCallInvite(evt) {
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);
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' });
}
export async function acceptIncomingCall() {
@ -1534,3 +1625,37 @@ export async function hangupActiveCall() {
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 MAX_LEN = 32;
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
const ALLOWED_CHARS_RE = /^[A-Za-z0-9_-]+$/;
export function normalizeChannelDisplayName(value) {
if (value == null) return '';
return String(value).trim().replace(/\s+/g, ' ');
return String(value).trim();
}
export function normalizeChannelDescription(value) {
@ -16,24 +16,9 @@ export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return '';
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
let out = '';
let pendingSeparator = false;
for (const ch of lowered) {
if (ch === ' ' || ch === '_' || ch === '-') {
pendingSeparator = out.length > 0;
continue;
}
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
return '';
}
if (pendingSeparator && out.length > 0) out += '-';
out += ch;
pendingSeparator = false;
}
return out.replace(/-+$/g, '');
const lowered = normalized.toLowerCase();
if (!ALLOWED_CHARS_RE.test(lowered)) return '';
return lowered;
}
export function validateChannelDisplayName(value) {
@ -73,7 +58,7 @@ export function channelNameErrorText(code) {
case 'too_long':
return 'Название слишком длинное: максимум 32 символа.';
case 'bad_chars':
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
return 'Разрешены только латиница, цифры, _ и -.';
case 'reserved':
return 'Название "0" зарезервировано.';
default:

View File

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

View File

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

View File

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

View File

@ -360,35 +360,7 @@ public final class DatabaseInitializer {
ON message_stats (to_login);
""");
// 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)
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS reactions_state (
from_login TEXT NOT NULL,

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = DatabaseInitializer.SCHEMA_VERSION_1;
private static final int LATEST_SCHEMA_VERSION = 2;
private final String jdbcUrl;
@ -84,6 +84,7 @@ public final class SqliteDbController {
private void applyMigration(int targetVersion) {
switch (targetVersion) {
case 1 -> migrateToV1();
case 2 -> migrateToV2();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@ -123,6 +124,29 @@ 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() {
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
@ -183,23 +207,6 @@ 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 {
st.executeUpdate("""
@ -246,17 +253,6 @@ 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 {
st.executeUpdate("""
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 MAX_DISPLAY_NAME_LENGTH = 32;
private static final Pattern DISPLAY_ALLOWED_PATTERN =
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
Pattern.compile("^[A-Za-z0-9_-]+$");
private ChannelNameRules() {}
public static String normalizeDisplayName(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim();
}
public static String requireValidDisplayNameForCreate(String rawName) {
@ -40,45 +40,10 @@ public final class ChannelNameRules {
throw new IllegalArgumentException("channelName is blank");
}
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
StringBuilder slug = new StringBuilder(lowered.length());
boolean pendingSeparator = false;
for (int i = 0; i < lowered.length(); ) {
int cp = lowered.codePointAt(i);
i += Character.charCount(cp);
if (cp == ' ' || cp == '_' || cp == '-') {
pendingSeparator = slug.length() > 0;
continue;
}
if (!isLatinOrCyrillicOrDigit(cp)) {
throw new IllegalArgumentException("channelName contains unsupported characters");
}
if (pendingSeparator && slug.length() > 0) {
slug.append('-');
}
pendingSeparator = false;
slug.appendCodePoint(cp);
String lowered = normalized.toLowerCase(Locale.ROOT);
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) {
throw new IllegalArgumentException("channelName contains unsupported characters");
}
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;
return lowered;
}
}

View File

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

View File

@ -142,6 +142,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_zero_writes_disabled" -> "Запись в канал 0 временно отключена";
case "channel_name_already_exists" -> "Такое название канала уже занято";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
default -> "Ошибка: " + code;
@ -337,6 +338,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
prevLineHash32 = bl.prevLineBlockHash32();
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)
if (prevLineNumber != null && prevLineNumber == -1) {
lineCode = null;

View File

@ -33,7 +33,7 @@ final class ChannelsReadSupport {
}
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "0";
if (rootNumber == 0) return "news";
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@ -212,111 +212,6 @@ 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 {
if (rootNumber == 0) return "";

View File

@ -108,22 +108,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
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);
}
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;
} catch (Exception e) {
log.error("GetChannelMessages failed", e);

View File

@ -178,9 +178,6 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
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) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
ci.setOwnerBlockchainName(row.bchName);
@ -229,4 +226,3 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int msgSubType;
}
}

View File

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

View File

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

View File

@ -25,6 +25,7 @@ import java.util.regex.Pattern;
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 AVATAR_AR_TOKEN_PATTERN = Pattern.compile("(?:^|,)\\s*AR:([A-Za-z0-9_-]{43})\\s*(?:,|$)");
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@ -201,8 +202,18 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private String extractArAvatarTxId(String rawValue) {
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
if (!value.startsWith("AR:")) return null;
String txId = value.substring(3).trim();
if (value.isEmpty()) return null;
var tokenMatch = AVATAR_AR_TOKEN_PATTERN.matcher(value);
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;
return txId;
}

View File

@ -51,12 +51,18 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
String turnUsername = "";
String turnPassword = "";
List<Net_GetCallIceConfig_Response.TurnServerConfig> turnServers = buildTurnServers(ctx, nowMs, ttlSec);
String sharedSecret = readStr("call.ice.turn.sharedSecret", "");
String staticUsername = readStr("call.ice.turn.username", "");
String staticPassword = readStr("call.ice.turn.password", "");
if (!turnUrls.isEmpty()) {
if (!turnServers.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()) {
long expiresEpochSec = nowMs / 1000L + ttlSec;
expiresAtMs = expiresEpochSec * 1000L;
@ -78,6 +84,7 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
resp.setTurnUrls(turnUrls);
resp.setTurnUsername(turnUsername);
resp.setTurnPassword(turnPassword);
resp.setTurnServers(turnServers);
resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank());
resp.setGeneratedAtMs(nowMs);
resp.setExpiresAtMs(expiresAtMs);
@ -85,6 +92,40 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
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) {
String value = CONFIG.getParam(key);
if (value == null || value.isBlank()) return fallback;
@ -146,4 +187,3 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
}
}
}

View File

@ -6,10 +6,27 @@ import java.util.ArrayList;
import java.util.List;
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> turnUrls = new ArrayList<>();
private String turnUsername = "";
private String turnPassword = "";
private List<TurnServerConfig> turnServers = new ArrayList<>();
private boolean turnEnabled;
private long generatedAtMs;
private long expiresAtMs;
@ -27,6 +44,9 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
public String getTurnPassword() { return 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 void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; }
@ -39,4 +59,3 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
public int getTtlSec() { return ttlSec; }
public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; }
}

View File

@ -1,29 +0,0 @@
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.messages.entyties.Net_CallInviteBroadcast_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Response;
import server.logic.ws_protocol.JSON.push.FcmPushSender;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.PushTokensDAO;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.PushTokenEntry;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import java.util.HashSet;
@ -26,6 +26,7 @@ import java.util.Set;
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_INVITE = 100;
private static final long PUSH_CALL_TTL_MS = 10_000L;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@ -49,12 +50,13 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
String from = ctx.getLogin();
String to = targetUser.getLogin();
long timeMs = System.currentTimeMillis();
long expiresAtMs = timeMs + PUSH_CALL_TTL_MS;
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
List<ActiveSessionEntry> allTargetSessions = ActiveSessionsDAO.getInstance().getByLogin(to);
int wsDelivered = 0;
int fcmDelivered = 0;
int webPushDelivered = 0;
Set<String> activeSessionIds = new HashSet<>();
for (ConnectionContext targetCtx : activeSessions) {
@ -74,14 +76,31 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
if (sent) wsDelivered++;
}
for (PushTokenEntry token : tokens) {
boolean pushed = FcmPushSender.sendNotification(
token.getToken(),
"Входящий звонок",
from + " пытается дозвониться",
callId
for (ActiveSessionEntry session : allTargetSessions) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
continue;
}
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
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) fcmDelivered++;
if (pushed) webPushDelivered++;
}
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
@ -90,7 +109,27 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
resp.setStatus(WireCodes.Status.OK);
resp.setCallId(callId);
resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(fcmDelivered);
resp.setDeliveredFcmSessions(webPushDelivered);
resp.setDeliveredWebPushSessions(webPushDelivered);
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,18 +9,25 @@ 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_CallSignalToSession_Request;
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.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
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;
@Override
@ -72,7 +79,34 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
if (type == TYPE_ACCEPT) {
notifyAcceptedOnOtherSessions(ctx, callId);
notifyStopOnOtherSessions(
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();
@ -83,31 +117,81 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
return resp;
}
private void notifyAcceptedOnOtherSessions(ConnectionContext accepterCtx, String callId) {
if (accepterCtx == null) return;
String login = accepterCtx.getLogin();
String acceptedSessionId = accepterCtx.getSessionId();
if (login == null || login.isBlank() || acceptedSessionId == null || acceptedSessionId.isBlank() || callId == null || callId.isBlank()) {
private void notifyStopOnOtherSessions(
String targetLogin,
String excludeSessionId,
String fromLogin,
String fromSessionId,
String callId,
String reason
) throws Exception {
if (isBlank(targetLogin) || isBlank(callId)) {
return;
}
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(login);
Set<String> onlineSessionIds = new HashSet<>();
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
for (ConnectionContext siblingCtx : sameUserSessions) {
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
if (acceptedSessionId.equals(siblingCtx.getSessionId())) continue;
onlineSessionIds.add(String.valueOf(siblingCtx.getSessionId() == null ? "" : siblingCtx.getSessionId()).trim());
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
String siblingEventId = NetIdGenerator.eventId("evt");
ObjectNode siblingPayload = MAPPER.createObjectNode();
siblingPayload.put("eventId", siblingEventId);
siblingPayload.put("fromLogin", login);
siblingPayload.put("fromSessionId", acceptedSessionId);
siblingPayload.put("toLogin", login);
siblingPayload.put("fromLogin", fromLogin);
siblingPayload.put("fromSessionId", fromSessionId);
siblingPayload.put("toLogin", targetLogin);
siblingPayload.put("callId", callId);
siblingPayload.put("type", TYPE_HANGUP);
siblingPayload.put("data", "accepted_on_other_device");
siblingPayload.put("data", reason);
siblingPayload.put("timeMs", System.currentTimeMillis());
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

@ -1,13 +0,0 @@
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

@ -1,6 +0,0 @@
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,6 +6,7 @@ public class Net_CallInviteBroadcast_Response extends Net_Response {
private String callId;
private int deliveredWsSessions;
private int deliveredFcmSessions;
private int deliveredWebPushSessions;
public String getCallId() { return callId; }
public void setCallId(String callId) { this.callId = callId; }
@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Response extends Net_Response {
public int getDeliveredFcmSessions() { return 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,16 +9,18 @@ import java.util.jar.JarFile;
public class IT_DeployRestartNoCleanNoTestsMain {
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "player");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server");
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 LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
public static void main(String[] args) {
ensureSudoNoPasswordOrThrow();
// 1) stop service на сервере
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
@ -40,6 +42,15 @@ public class IT_DeployRestartNoCleanNoTestsMain {
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() {
for (int i = 0; i < 50; i++) {
int code = ssh("ss -ltnp | grep -q ':7070'");

View File

@ -0,0 +1,170 @@
# Как устроены каналы в блокчейне 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,6 +16,23 @@
5. Если пара реально добавилась в БД, сервер запускает realtime-доставку в активные сессии целевых пользователей.
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

@ -0,0 +1,44 @@
# Логика установки звонка через сервер (актуальная)
## 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

@ -0,0 +1,141 @@
# Типы блоков и сообщений 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).
Так получится и удобство, и проверяемость.