Compare commits
10 Commits
c44d755ce0
...
97d37a2eb6
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
97d37a2eb6 | ||
|
|
a23d090bc1 | ||
|
|
4956ba7352 | ||
|
|
acdd6c928b | ||
|
|
6774c26ea1 | ||
|
|
ef0cd2cb7d | ||
|
|
e921b06826 | ||
|
|
c0c29b74ab | ||
|
|
310863faec | ||
|
|
b05da86197 |
26
AGENTS.md
26
AGENTS.md
@ -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*`.
|
||||
|
||||
53
Deploy Server/servers-inventory.md
Normal file
53
Deploy Server/servers-inventory.md
Normal 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.
|
||||
@ -31,3 +31,11 @@
|
||||
- количество успешных вставок пар,
|
||||
- доля доставок в WS/push,
|
||||
- количество ретраев межсерверной пересылки.
|
||||
|
||||
## 6) Ограничение текущих звонков (важно)
|
||||
- Сейчас звонки работают только в рамках одного сигнального сервера (или единого контура, где обе стороны уже подключены).
|
||||
- Сценарий «пользователь A на своих серверах, пользователь B на других серверах» пока не поддержан.
|
||||
- TODO на будущее:
|
||||
- временная межсерверная авторизация/сессия для старта звонка,
|
||||
- отправка сигнальных сообщений между разными серверами пользователей,
|
||||
- аккуратное завершение временной сессии после установления/завершения звонка.
|
||||
|
||||
41
TODO_Звонки_межсерверность.md
Normal file
41
TODO_Звонки_межсерверность.md
Normal 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 (например 1–3 минуты),
|
||||
- минимальные права только на `CallInviteBroadcast/CallSignalToSession`.
|
||||
|
||||
3. Маршрутизация сессий пользователя между серверами:
|
||||
- где находится активная сессия callee,
|
||||
- как доставлять `stop_call` и terminal-сигналы на все устройства callee.
|
||||
|
||||
4. Идемпотентность и дедупликация:
|
||||
- защита от повторов межсерверных сигналов по `callId + eventId`,
|
||||
- корректная обработка out-of-order событий.
|
||||
|
||||
5. Наблюдаемость:
|
||||
- метрики межсерверной доставки сигналов,
|
||||
- диагностика по стадиям звонка и причинам срыва.
|
||||
|
||||
## Временный рабочий подход (до межсерверности)
|
||||
- Держать звонки в одном сигнальном контуре.
|
||||
- Использовать WebPush как fallback-уведомление (`incoming_call`/`stop_call`) для офлайн-сессий.
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.35
|
||||
server.version=1.2.29
|
||||
client.version=1.2.44
|
||||
server.version=1.2.38
|
||||
|
||||
105
docs/Ответ_на_вопрос_о_блокчейне_каналах_и_расширяемости.md
Normal file
105
docs/Ответ_на_вопрос_о_блокчейне_каналах_и_расширяемости.md
Normal 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
24
predeploy/servers.md
Normal 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`).
|
||||
17
shine-TURN-server/README.md
Normal file
17
shine-TURN-server/README.md
Normal 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).
|
||||
@ -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);
|
||||
})());
|
||||
});
|
||||
|
||||
@ -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 }));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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, 'Не удалось создать канал.');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: 'Авторы' },
|
||||
];
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 "";
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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'");
|
||||
|
||||
170
Как_устроены_каналы_в_блокчейне_SHiNE.md
Normal file
170
Как_устроены_каналы_в_блокчейне_SHiNE.md
Normal 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 может показывать удобный “чатовый” вид, но источник истины — блоки.
|
||||
@ -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» без авторизованной сессии нельзя.
|
||||
|
||||
## Почему допускаются дубли сети
|
||||
- В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения.
|
||||
- Дедупликация делается на уровне БД по ключам записи.
|
||||
|
||||
44
Логика_установки_соединения_через_сервер.md
Normal file
44
Логика_установки_соединения_через_сервер.md
Normal 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.
|
||||
141
Типы_блоков_и_сообщений_SHiNE.md
Normal file
141
Типы_блоков_и_сообщений_SHiNE.md
Normal 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).
|
||||
|
||||
Так получится и удобство, и проверяемость.
|
||||
Loading…
Reference in New Issue
Block a user