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.
|
- `client.version` — версия клиентского UI.
|
||||||
- `server.version` — версия серверной части.
|
- `server.version` — версия серверной части.
|
||||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
|
||||||
|
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
|
||||||
|
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||||
|
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
|
||||||
|
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
|
||||||
|
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
|
||||||
|
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
|
||||||
|
|
||||||
|
## Логи звонков (установка соединения)
|
||||||
|
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
|
||||||
|
- На проде специальный файл для звонков:
|
||||||
|
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
|
||||||
|
- Общий серверный лог (и ротации) на проде:
|
||||||
|
- `/home/player/SHiNE/shine-server/logs/app.log`
|
||||||
|
- `/home/player/SHiNE/shine-server/logs/app.YYYY-MM-DD.log`
|
||||||
|
- Для анализа причин недозвона в первую очередь фильтровать записи по ключам:
|
||||||
|
- `CallDeliveryReport`
|
||||||
|
- `call_connected`
|
||||||
|
- `outgoing_failed`
|
||||||
|
- `incoming_failed`
|
||||||
|
- `call_busy`
|
||||||
|
- `call_declined`
|
||||||
|
- `unknown_error`
|
||||||
|
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
|
||||||
|
|||||||
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,
|
- доля доставок в 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
|
client.version=1.2.44
|
||||||
server.version=1.2.29
|
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('install', () => self.skipWaiting());
|
||||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||||
|
self.__shineStoppedCalls = self.__shineStoppedCalls || new Map();
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
const data = event?.data || {};
|
const data = event?.data || {};
|
||||||
if (data.type === 'SKIP_WAITING') {
|
if (data.type === 'SKIP_WAITING') {
|
||||||
@ -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) => {
|
self.addEventListener('push', (event) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
let rawText = '';
|
let rawText = '';
|
||||||
@ -27,13 +84,12 @@ self.addEventListener('push', (event) => {
|
|||||||
if (event.data) {
|
if (event.data) {
|
||||||
const text = event.data.text();
|
const text = event.data.text();
|
||||||
rawText = text || '';
|
rawText = text || '';
|
||||||
try {
|
const json = decodePushJson(rawText);
|
||||||
const json = JSON.parse(rawText || '{}');
|
|
||||||
kind = String(json.kind || '');
|
kind = String(json.kind || '');
|
||||||
title = String(json.title || '');
|
title = String(json.title || '');
|
||||||
body = String(json.text || '');
|
body = String(json.text || '');
|
||||||
fromLogin = String(json.fromLogin || '');
|
fromLogin = String(json.fromLogin || '');
|
||||||
} catch {
|
if (!kind && rawText) {
|
||||||
body = rawText || '';
|
body = rawText || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -41,34 +97,106 @@ self.addEventListener('push', (event) => {
|
|||||||
// ignore
|
// 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'
|
const notificationTitle = kind === 'test_push'
|
||||||
? (title || 'SHiNE: тестовый push')
|
? (title || 'SHiNE: тестовый push')
|
||||||
: 'SHiNE: входящее сообщение';
|
: (kind === 'incoming_call'
|
||||||
|
? 'SHiNE: входящий звонок'
|
||||||
|
: 'SHiNE: входящее сообщение');
|
||||||
|
|
||||||
const notifyPromise = shouldNotify
|
const notifyPromise = shouldNotify
|
||||||
? self.registration.showNotification(notificationTitle, {
|
? self.registration.showNotification(notificationTitle, {
|
||||||
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
|
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
|
||||||
tag: kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message',
|
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
|
||||||
renotify: true,
|
renotify: true,
|
||||||
|
requireInteraction: kind === 'incoming_call',
|
||||||
|
data: {
|
||||||
|
kind,
|
||||||
|
callId,
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
toLogin,
|
||||||
|
sentAtMs,
|
||||||
|
expiresAtMs,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
actions: kind === 'incoming_call'
|
||||||
|
? [
|
||||||
|
{ action: 'accept', title: 'Ответить' },
|
||||||
|
{ action: 'decline', title: 'Сбросить' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
})
|
})
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const closeOnStopPromise = kind === 'stop_call' && callId
|
||||||
|
? closeCallNotification(callId)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
event.waitUntil(Promise.all([
|
event.waitUntil(Promise.all([
|
||||||
notifyPromise,
|
notifyPromise,
|
||||||
|
closeOnStopPromise,
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
kind,
|
kind,
|
||||||
body,
|
body,
|
||||||
fromLogin,
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
toLogin,
|
||||||
|
callId,
|
||||||
|
sentAtMs,
|
||||||
|
expiresAtMs,
|
||||||
|
reason,
|
||||||
|
stale: isExpiredIncomingCall || isIncomingCallAlreadyStopped,
|
||||||
rawText,
|
rawText,
|
||||||
receivedAt: Date.now(),
|
receivedAt: nowMs,
|
||||||
}),
|
}),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification?.close();
|
event.notification?.close();
|
||||||
|
const action = String(event?.action || '').trim().toLowerCase();
|
||||||
|
const data = event?.notification?.data || {};
|
||||||
|
const payload = {
|
||||||
|
kind: String(data.kind || '').trim(),
|
||||||
|
callId: String(data.callId || '').trim(),
|
||||||
|
fromLogin: String(data.fromLogin || '').trim(),
|
||||||
|
fromSessionId: String(data.fromSessionId || '').trim(),
|
||||||
|
toLogin: String(data.toLogin || '').trim(),
|
||||||
|
sentAtMs: Number(data.sentAtMs || 0),
|
||||||
|
expiresAtMs: Number(data.expiresAtMs || 0),
|
||||||
|
reason: String(data.reason || '').trim(),
|
||||||
|
};
|
||||||
|
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
|
if ((action === 'accept' || action === 'decline') && payload.callId) {
|
||||||
|
await broadcastCallActionToClients(action, payload);
|
||||||
|
}
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
const existing = allClients.find((client) => {
|
const existing = allClients.find((client) => {
|
||||||
try {
|
try {
|
||||||
@ -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) {
|
if (existing) {
|
||||||
|
try {
|
||||||
|
if (action === 'accept' || action === 'decline') {
|
||||||
|
existing.postMessage({
|
||||||
|
type: 'SHINE_CALL_PUSH_ACTION',
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
await existing.focus();
|
await existing.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.clients.openWindow('./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 { initPwaPush } from './services/pwa-push-service.js';
|
||||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||||
import {
|
import {
|
||||||
|
handleCallPushAction,
|
||||||
handleIncomingCallInvite,
|
handleIncomingCallInvite,
|
||||||
|
handleIncomingCallPush,
|
||||||
handleIncomingCallSignal,
|
handleIncomingCallSignal,
|
||||||
|
handleStopCallPush,
|
||||||
setCallDebugReporter,
|
setCallDebugReporter,
|
||||||
startDebugConnectionAsInitiator,
|
startDebugConnectionAsInitiator,
|
||||||
startDebugConnectionAsResponder,
|
startDebugConnectionAsResponder,
|
||||||
@ -127,6 +130,7 @@ let uiUpdateReloadScheduled = false;
|
|||||||
let pwaUpdateCheckAttempted = false;
|
let pwaUpdateCheckAttempted = false;
|
||||||
let uiVersionCheckInFlight = false;
|
let uiVersionCheckInFlight = false;
|
||||||
let uiVersionPeriodicIntervalId = null;
|
let uiVersionPeriodicIntervalId = null;
|
||||||
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||||
initPwaInstallPromptHandling();
|
initPwaInstallPromptHandling();
|
||||||
@ -220,6 +224,85 @@ function startConnectionCountdown() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savePendingCallPushAction(action, payload = {}) {
|
||||||
|
try {
|
||||||
|
const item = {
|
||||||
|
action: String(action || '').trim().toLowerCase(),
|
||||||
|
payload: payload || {},
|
||||||
|
savedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(CALL_PUSH_PENDING_ACTION_KEY, JSON.stringify(item));
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingCallPushAction() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const action = String(parsed?.action || '').trim().toLowerCase();
|
||||||
|
if (action !== 'accept' && action !== 'decline') return null;
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
payload: parsed?.payload || {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingCallPushAction() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(CALL_PUSH_PENDING_ACTION_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCallPushActionFromUrlIfAny() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const action = String(params.get('callPushAction') || '').trim().toLowerCase();
|
||||||
|
const rawPayload = String(params.get('callPushPayload') || '');
|
||||||
|
if (action !== 'accept' && action !== 'decline') return;
|
||||||
|
let payload = {};
|
||||||
|
if (rawPayload) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(decodeURIComponent(rawPayload));
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
params.delete('callPushAction');
|
||||||
|
params.delete('callPushPayload');
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
|
||||||
|
window.history.replaceState({}, '', nextUrl);
|
||||||
|
} catch {
|
||||||
|
// ignore URL parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPendingCallPushActionIfPossible() {
|
||||||
|
if (!state.session.isAuthorized) return;
|
||||||
|
const pending = loadPendingCallPushAction();
|
||||||
|
if (!pending) return;
|
||||||
|
clearPendingCallPushAction();
|
||||||
|
try {
|
||||||
|
await handleCallPushAction(pending.action, pending.payload || {});
|
||||||
|
} catch (error) {
|
||||||
|
addAppLogEntry({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'web-push',
|
||||||
|
message: 'Не удалось выполнить действие звонка из push',
|
||||||
|
details: { action: pending.action, error: error?.message || 'unknown' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setConnectionStatus(nextState, text = '') {
|
function setConnectionStatus(nextState, text = '') {
|
||||||
const state = String(nextState || '').trim();
|
const state = String(nextState || '').trim();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
@ -677,9 +760,13 @@ async function ensureSessionRuntimeStarted() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
|
await processPendingCallPushActionIfPossible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
consumeCallPushActionFromUrlIfAny();
|
||||||
|
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'app',
|
source: 'app',
|
||||||
@ -698,12 +785,23 @@ async function init() {
|
|||||||
|
|
||||||
setSessionAuthorizedHandler(() => {
|
setSessionAuthorizedHandler(() => {
|
||||||
void ensureSessionRuntimeStarted();
|
void ensureSessionRuntimeStarted();
|
||||||
|
void processPendingCallPushActionIfPossible();
|
||||||
});
|
});
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
const data = event?.data || {};
|
const data = event?.data || {};
|
||||||
|
if (data.type === 'SHINE_CALL_PUSH_ACTION') {
|
||||||
|
const action = String(data.action || '').trim().toLowerCase();
|
||||||
|
const payload = data.payload || {};
|
||||||
|
if (action === 'accept' || action === 'decline') {
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
void processPendingCallPushActionIfPossible();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||||
|
|
||||||
const payload = data.payload || {};
|
const payload = data.payload || {};
|
||||||
const kind = String(payload.kind || '').trim();
|
const kind = String(payload.kind || '').trim();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -723,6 +821,11 @@ async function init() {
|
|||||||
message: 'Получено push-событие в service worker',
|
message: 'Получено push-событие в service worker',
|
||||||
details: payload,
|
details: payload,
|
||||||
});
|
});
|
||||||
|
if (kind === 'incoming_call' && !payload.stale && state.session.isAuthorized) {
|
||||||
|
void handleIncomingCallPush(payload);
|
||||||
|
} else if (kind === 'stop_call' && state.session.isAuthorized) {
|
||||||
|
void handleStopCallPush(payload);
|
||||||
|
}
|
||||||
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
|
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { state } from '../state.js';
|
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';
|
import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js';
|
||||||
|
|
||||||
function normalizeLogin(value) {
|
function normalizeLogin(value) {
|
||||||
@ -52,6 +52,8 @@ export function renderUserAvatar({
|
|||||||
if (!validateArweaveTxId(txId)) {
|
if (!validateArweaveTxId(txId)) {
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
const sha256Hex = String(avatar?.sha256Hex || avatar?.sha256 || '').trim().toLowerCase();
|
||||||
|
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = 'Аватар';
|
img.alt = 'Аватар';
|
||||||
@ -65,7 +67,7 @@ export function renderUserAvatar({
|
|||||||
setLoadedState(false);
|
setLoadedState(false);
|
||||||
|
|
||||||
const gateway = state?.entrySettings?.arweaveServer;
|
const gateway = state?.entrySettings?.arweaveServer;
|
||||||
void getCachedAvatarObjectUrl({ gateway, txId })
|
void getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex })
|
||||||
.then((objectUrl) => {
|
.then((objectUrl) => {
|
||||||
const directUrl = buildArweaveDataUrl({ gateway, txId });
|
const directUrl = buildArweaveDataUrl({ gateway, txId });
|
||||||
let triedDirectUrl = false;
|
let triedDirectUrl = false;
|
||||||
@ -83,6 +85,12 @@ export function renderUserAvatar({
|
|||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
if (!triedDirectUrl) {
|
if (!triedDirectUrl) {
|
||||||
|
if (expectedSha256Hex) {
|
||||||
|
releaseObjectUrl();
|
||||||
|
img.removeAttribute('src');
|
||||||
|
setLoadedState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
triedDirectUrl = true;
|
triedDirectUrl = true;
|
||||||
releaseObjectUrl();
|
releaseObjectUrl();
|
||||||
img.src = directUrl;
|
img.src = directUrl;
|
||||||
@ -95,6 +103,11 @@ export function renderUserAvatar({
|
|||||||
img.src = objectUrl;
|
img.src = objectUrl;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (expectedSha256Hex) {
|
||||||
|
img.removeAttribute('src');
|
||||||
|
setLoadedState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let directUrl = '';
|
let directUrl = '';
|
||||||
try {
|
try {
|
||||||
directUrl = buildArweaveDataUrl({ gateway, txId });
|
directUrl = buildArweaveDataUrl({ gateway, txId });
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import {
|
|||||||
buildArweaveDataUrl,
|
buildArweaveDataUrl,
|
||||||
getArweaveUploadPrice,
|
getArweaveUploadPrice,
|
||||||
prepareAvatarImageFile,
|
prepareAvatarImageFile,
|
||||||
|
sha256HexFromArrayBuffer,
|
||||||
uploadArweaveFile,
|
uploadArweaveFile,
|
||||||
validateArweaveTxId,
|
validateArweaveTxId,
|
||||||
|
validateSha256Hex,
|
||||||
validateAvatarSourceFile,
|
validateAvatarSourceFile,
|
||||||
} from '../services/arweave-file-service.js';
|
} from '../services/arweave-file-service.js';
|
||||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||||
@ -69,6 +71,7 @@ export function openAvatarWizard({
|
|||||||
let optimized = null;
|
let optimized = null;
|
||||||
let priceInfo = null;
|
let priceInfo = null;
|
||||||
let uploadedTxId = '';
|
let uploadedTxId = '';
|
||||||
|
let uploadedSha256Hex = '';
|
||||||
|
|
||||||
function revokePreviewUrl() {
|
function revokePreviewUrl() {
|
||||||
if (!lastPreviewUrl) return;
|
if (!lastPreviewUrl) return;
|
||||||
@ -158,6 +161,7 @@ export function openAvatarWizard({
|
|||||||
const showStepExistingPreview = async (txId) => {
|
const showStepExistingPreview = async (txId) => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
|
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
|
||||||
|
let existingSha256Hex = '';
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
<div class="modal" data-avatar-wizard-modal="true">
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
<div class="modal-card stack avatar-wizard-card">
|
||||||
@ -165,7 +169,7 @@ export function openAvatarWizard({
|
|||||||
<div class="avatar-preview-circle avatar-wizard-preview">
|
<div class="avatar-preview-circle avatar-wizard-preview">
|
||||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
|
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
<p class="avatar-wizard-error" data-error="true"></p>
|
||||||
<div class="avatar-wizard-actions">
|
<div class="avatar-wizard-actions">
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||||
@ -186,15 +190,19 @@ export function openAvatarWizard({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ensurePreviewImage(previewUrl, imageEl);
|
await ensurePreviewImage(previewUrl, imageEl);
|
||||||
|
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
|
||||||
|
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
|
||||||
|
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
|
||||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
|
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNodeText(errorEl, 'Не удалось загрузить изображение по этому Transaction ID');
|
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
|
||||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveBtn?.addEventListener('click', async () => {
|
saveBtn?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await saveProfileAvatarArweave(cleanLogin, txId);
|
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
|
||||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||||
close(true, resolve);
|
close(true, resolve);
|
||||||
} catch {
|
} catch {
|
||||||
@ -238,7 +246,7 @@ export function openAvatarWizard({
|
|||||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
<div class="avatar-wizard-meta" data-meta="true"></div>
|
||||||
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
|
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
<p class="avatar-wizard-error" data-error="true"></p>
|
||||||
<div class="avatar-wizard-actions">
|
<div class="avatar-wizard-actions">
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||||
@ -259,6 +267,7 @@ export function openAvatarWizard({
|
|||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
optimized = null;
|
optimized = null;
|
||||||
priceInfo = null;
|
priceInfo = null;
|
||||||
|
uploadedSha256Hex = '';
|
||||||
|
|
||||||
modal?.addEventListener('click', (event) => {
|
modal?.addEventListener('click', (event) => {
|
||||||
if (event.target === modal) close(false, resolve);
|
if (event.target === modal) close(false, resolve);
|
||||||
@ -295,6 +304,7 @@ export function openAvatarWizard({
|
|||||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
||||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
||||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
||||||
|
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
||||||
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
|
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -338,9 +348,13 @@ export function openAvatarWizard({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
uploadedTxId = String(uploaded.id || '').trim();
|
uploadedTxId = String(uploaded.id || '').trim();
|
||||||
|
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
|
||||||
if (!uploadedTxId) {
|
if (!uploadedTxId) {
|
||||||
throw new Error('Пустой Transaction ID');
|
throw new Error('Пустой Transaction ID');
|
||||||
}
|
}
|
||||||
|
if (!validateSha256Hex(uploadedSha256Hex)) {
|
||||||
|
throw new Error('Некорректный SHA256');
|
||||||
|
}
|
||||||
showStepUploaded();
|
showStepUploaded();
|
||||||
} catch {
|
} catch {
|
||||||
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
|
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
|
||||||
@ -357,6 +371,8 @@ export function openAvatarWizard({
|
|||||||
<h3 class="modal-title">Файл загружен в Arweave</h3>
|
<h3 class="modal-title">Файл загружен в Arweave</h3>
|
||||||
<p class="meta-muted">Transaction ID:</p>
|
<p class="meta-muted">Transaction ID:</p>
|
||||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
||||||
|
<p class="meta-muted">SHA-256:</p>
|
||||||
|
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
<p class="avatar-wizard-error" data-error="true"></p>
|
||||||
<div class="avatar-wizard-actions">
|
<div class="avatar-wizard-actions">
|
||||||
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
|
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
|
||||||
@ -378,7 +394,7 @@ export function openAvatarWizard({
|
|||||||
});
|
});
|
||||||
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
|
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await saveProfileAvatarArweave(cleanLogin, uploadedTxId);
|
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
|
||||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||||
close(true, resolve);
|
close(true, resolve);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -44,11 +44,11 @@ export function render({ navigate }) {
|
|||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<strong class="channel-head-title">Создание канала</strong>
|
<strong class="channel-head-title">Создание канала</strong>
|
||||||
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
|
||||||
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||||
|
|
||||||
<label for="channel-name">Название канала</label>
|
<label for="channel-name">Название канала</label>
|
||||||
<input id="channel-name" class="input" maxlength="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>
|
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
<label for="channel-description">Описание канала (необязательно)</label>
|
<label for="channel-description">Описание канала (необязательно)</label>
|
||||||
@ -124,18 +124,14 @@ export function render({ navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await authService.addBlockCreateChannel({
|
await authService.addBlockCreateChannel({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channelName: normalizeChannelDisplayName(check.name),
|
channelName: normalizeChannelDisplayName(check.name),
|
||||||
channelDescription: normalizeChannelDescription(check.description),
|
channelDescription: normalizeChannelDescription(check.description),
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
|
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||||
const successMessage = created?.usedLegacyDescriptionFallback && created?.savedDescriptionViaUserParam
|
|
||||||
? `${baseMessage} Описание сохранено через блок параметра.`
|
|
||||||
: baseMessage;
|
|
||||||
persistCreateSuccessFlash(successMessage);
|
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
||||||
|
|||||||
@ -98,6 +98,24 @@ function buildAbsoluteRouteUrl(routePath = '') {
|
|||||||
|
|
||||||
function parseThreadSelector(route) {
|
function parseThreadSelector(route) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
|
if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) {
|
||||||
|
return {
|
||||||
|
short: {
|
||||||
|
ownerLogin: String(params.ownerLogin || '').trim(),
|
||||||
|
channelName: String(params.channelName || '').trim(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
blockchainName: '',
|
||||||
|
blockNumber: toSafeInt(params.messageBlockNumber),
|
||||||
|
blockHash: normalizeRouteHash(params.messageBlockHash),
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
ownerBlockchainName: '',
|
||||||
|
rootBlockNumber: null,
|
||||||
|
rootBlockHash: '0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||||
if (!params.messageBlockchainName || blockNumber == null) return null;
|
if (!params.messageBlockchainName || blockNumber == null) return null;
|
||||||
|
|
||||||
@ -153,7 +171,17 @@ function buildBackRoute(selector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildThreadRouteFromTarget(target, 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 [
|
return [
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
encodeRoutePart(target.blockchainName),
|
encodeRoutePart(target.blockchainName),
|
||||||
@ -293,13 +321,6 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
|
|
||||||
card.append(meta, body, stats);
|
card.append(meta, body, stats);
|
||||||
|
|
||||||
if (options.showViews === true) {
|
|
||||||
const views = document.createElement('p');
|
|
||||||
views.className = 'thread-node-views';
|
|
||||||
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
|
|
||||||
card.append(views);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
const refKey = messageRefKey(target);
|
const refKey = messageRefKey(target);
|
||||||
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
||||||
@ -337,15 +358,19 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
likeButton.type = 'button';
|
likeButton.type = 'button';
|
||||||
likeButton.className = 'secondary-btn thread-like-btn';
|
likeButton.className = 'secondary-btn thread-like-btn';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
|
likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
if (!isLiked) {
|
||||||
|
const ok = window.confirm('Поставить лайк?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
revealCounters();
|
revealCounters();
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
likeButton.textContent = 'Сияние...';
|
likeButton.textContent = 'Лайк...';
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -361,7 +386,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||||
replyButton.textContent = '⟳ Отразить';
|
replyButton.textContent = '💬 Ответить';
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
revealCounters();
|
revealCounters();
|
||||||
@ -373,7 +398,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'secondary-btn thread-share-btn';
|
shareButton.className = 'secondary-btn thread-share-btn';
|
||||||
shareButton.textContent = '↗ Транслировать';
|
shareButton.textContent = '↗ Отправить';
|
||||||
shareButton.addEventListener('click', async (event) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -454,10 +479,6 @@ export function render({ navigate, route }) {
|
|||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
|
||||||
const userIndicator = document.createElement('div');
|
|
||||||
userIndicator.className = 'card channels-user-chip';
|
|
||||||
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
|
|
||||||
|
|
||||||
const channelIndicator = document.createElement('div');
|
const channelIndicator = document.createElement('div');
|
||||||
channelIndicator.className = 'card channels-user-chip';
|
channelIndicator.className = 'card channels-user-chip';
|
||||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||||
@ -490,7 +511,11 @@ export function render({ navigate, route }) {
|
|||||||
const requireSigningSession = () => {
|
const requireSigningSession = () => {
|
||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
|
if (!login || !storagePwd) {
|
||||||
|
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||||
|
navigate('login-view');
|
||||||
|
throw new Error('Для этого действия нужно войти');
|
||||||
|
}
|
||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -562,7 +587,7 @@ export function render({ navigate, route }) {
|
|||||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
screen.append(userIndicator, channelIndicator, statusBox);
|
screen.append(channelIndicator, statusBox);
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
const invalid = document.createElement('div');
|
const invalid = document.createElement('div');
|
||||||
@ -576,7 +601,25 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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();
|
skeleton.remove();
|
||||||
|
|
||||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||||
@ -605,7 +648,7 @@ export function render({ navigate, route }) {
|
|||||||
if (focus) {
|
if (focus) {
|
||||||
const focusWrap = document.createElement('div');
|
const focusWrap = document.createElement('div');
|
||||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: false }));
|
||||||
screen.append(focusWrap);
|
screen.append(focusWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
|||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
const revealedCountersByRoute = new Map();
|
const revealedCountersByRoute = new Map();
|
||||||
const seenFlushTimersByRoute = new Map();
|
|
||||||
const seenPendingByRoute = new Map();
|
|
||||||
const firstUnreadJumpByRoute = new Map();
|
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -126,44 +123,16 @@ function buildAbsoluteRouteUrl(routePath = '') {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelDescriptionParamKey(selector) {
|
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
||||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
|
||||||
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
|
|
||||||
if (!owner || !Number.isFinite(rootNo)) return '';
|
|
||||||
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDescriptionOverride(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
return { hasOverride: false, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
|
|
||||||
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
|
|
||||||
return { hasOverride: false, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return { hasOverride: true, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue);
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
const value = typeof parsed.v === 'string' ? parsed.v : '';
|
|
||||||
return { hasOverride: true, description: value.trim() };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// legacy raw string value
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hasOverride: true, description: rawValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSelectorFromRoute(route, channelId) {
|
function buildSelectorFromRoute(route, channelId) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
|
|
||||||
|
if (params.ownerLogin && params.channelName) {
|
||||||
|
return {
|
||||||
|
ownerLogin: String(params.ownerLogin || '').trim(),
|
||||||
|
channelName: String(params.channelName || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (params.ownerBlockchainName) {
|
if (params.ownerBlockchainName) {
|
||||||
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
||||||
if (rootBlockNumber != null) {
|
if (rootBlockNumber != null) {
|
||||||
@ -186,6 +155,17 @@ function buildSelectorFromRoute(route, channelId) {
|
|||||||
|
|
||||||
function buildThreadRoute(messageRef, selector) {
|
function buildThreadRoute(messageRef, selector) {
|
||||||
if (!messageRef || !selector) return '';
|
if (!messageRef || !selector) return '';
|
||||||
|
const ownerLogin = String(selector.ownerLogin || '').trim();
|
||||||
|
const channelName = String(selector.channelName || '').trim();
|
||||||
|
if (ownerLogin && channelName) {
|
||||||
|
return [
|
||||||
|
'channel',
|
||||||
|
encodeRoutePart(ownerLogin),
|
||||||
|
encodeRoutePart(channelName),
|
||||||
|
messageRef.blockNumber,
|
||||||
|
normalizeRouteHash(messageRef.blockHash),
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
encodeRoutePart(messageRef.blockchainName),
|
encodeRoutePart(messageRef.blockchainName),
|
||||||
@ -395,88 +375,6 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="channel-edit-description-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">Описание канала</h3>
|
|
||||||
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
|
||||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
|
||||||
<div class="meta-muted inline-error" id="channel-description-error"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
|
|
||||||
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const textEl = root.querySelector('#channel-description-text');
|
|
||||||
const counterEl = root.querySelector('#channel-description-counter');
|
|
||||||
const errorEl = root.querySelector('#channel-description-error');
|
|
||||||
const submitEl = root.querySelector('#channel-description-submit');
|
|
||||||
const cancelEl = root.querySelector('#channel-description-cancel');
|
|
||||||
|
|
||||||
let inFlight = false;
|
|
||||||
|
|
||||||
const compute = () => {
|
|
||||||
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
|
|
||||||
const bytes = new TextEncoder().encode(value).length;
|
|
||||||
const ok = bytes <= 200;
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
bytes,
|
|
||||||
ok,
|
|
||||||
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const setBusy = (busy) => {
|
|
||||||
inFlight = !!busy;
|
|
||||||
submitEl.disabled = inFlight;
|
|
||||||
cancelEl.disabled = inFlight;
|
|
||||||
if (textEl) textEl.disabled = inFlight;
|
|
||||||
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateValidation = () => {
|
|
||||||
const check = compute();
|
|
||||||
counterEl.textContent = `${check.bytes} / 200 байт`;
|
|
||||||
errorEl.textContent = check.error;
|
|
||||||
submitEl.disabled = inFlight || !check.ok;
|
|
||||||
return check;
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelEl?.addEventListener('click', close);
|
|
||||||
textEl?.addEventListener('input', updateValidation);
|
|
||||||
submitEl?.addEventListener('click', async () => {
|
|
||||||
if (inFlight) return;
|
|
||||||
const check = updateValidation();
|
|
||||||
if (!check.ok) return;
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
errorEl.textContent = '';
|
|
||||||
try {
|
|
||||||
await onSubmit(check.value);
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
setBusy(false);
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (textEl) {
|
|
||||||
textEl.value = String(initialValue || '');
|
|
||||||
textEl.focus();
|
|
||||||
}
|
|
||||||
updateValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapApiMessageToPost(message, selector, localNumber) {
|
function mapApiMessageToPost(message, selector, localNumber) {
|
||||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||||
@ -502,8 +400,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
body: resolvedText || '(пусто)',
|
body: resolvedText || '(пусто)',
|
||||||
likesCount: Number(message?.likesCount || 0),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
viewCount: Number(message?.viewCount || 0),
|
|
||||||
seenByMe: message?.seenByMe === true,
|
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||||
@ -511,7 +407,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(route, channelId) {
|
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) {
|
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
|
||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
}
|
}
|
||||||
@ -520,37 +434,15 @@ async function loadFromApi(route, channelId) {
|
|||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
|
|
||||||
const unreadFromPayload = Number(payload.unreadCount || 0);
|
|
||||||
|
|
||||||
const readDescription = async () => {
|
|
||||||
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
|
||||||
const paramKey = channelDescriptionParamKey(selector);
|
|
||||||
if (!ownerLogin || !paramKey) return sourceDescription;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
|
|
||||||
const override = parseDescriptionOverride(paramPayload);
|
|
||||||
return override.hasOverride ? override.description : sourceDescription;
|
|
||||||
} catch {
|
|
||||||
return sourceDescription;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedDescription = await readDescription();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: resolvedDescription,
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
unreadCount: Number.isFinite(unreadFromPayload)
|
|
||||||
? Math.max(0, unreadFromPayload)
|
|
||||||
: posts.filter((post) => post.seenByMe !== true).length,
|
|
||||||
firstUnreadKey,
|
|
||||||
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||||
selector,
|
selector,
|
||||||
};
|
};
|
||||||
@ -665,11 +557,7 @@ function renderPostCard(post, {
|
|||||||
body.className = 'channel-message-body';
|
body.className = 'channel-message-body';
|
||||||
body.textContent = post.body;
|
body.textContent = post.body;
|
||||||
|
|
||||||
const views = document.createElement('p');
|
card.append(topRow, body);
|
||||||
views.className = 'channel-message-views';
|
|
||||||
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
|
|
||||||
|
|
||||||
card.append(topRow, body, views);
|
|
||||||
|
|
||||||
const refKey = messageRefKey(post.messageRef);
|
const refKey = messageRefKey(post.messageRef);
|
||||||
if (refKey) {
|
if (refKey) {
|
||||||
@ -703,19 +591,23 @@ function renderPostCard(post, {
|
|||||||
const isLiked = post.reactionState === 'liked';
|
const isLiked = post.reactionState === 'liked';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.innerHTML = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">✦</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||||
`;
|
`;
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
if (!isLiked) {
|
||||||
|
const ok = window.confirm('Поставить лайк?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
revealCounters();
|
revealCounters();
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
const labelEl = likeButton.querySelector('.channel-action-label');
|
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||||
if (labelEl) labelEl.textContent = 'Сияние...';
|
if (labelEl) labelEl.textContent = 'Лайк...';
|
||||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -724,7 +616,7 @@ function renderPostCard(post, {
|
|||||||
replyButton.className = 'channel-action-item channel-action-reply';
|
replyButton.className = 'channel-action-item channel-action-reply';
|
||||||
replyButton.innerHTML = `
|
replyButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
||||||
<span class="channel-action-label">Отразить</span>
|
<span class="channel-action-label">Ответить</span>
|
||||||
`;
|
`;
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -754,7 +646,7 @@ function renderPostCard(post, {
|
|||||||
shareButton.className = 'channel-action-item channel-action-share';
|
shareButton.className = 'channel-action-item channel-action-share';
|
||||||
shareButton.innerHTML = `
|
shareButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||||
<span class="channel-action-label">Транслировать</span>
|
<span class="channel-action-label">Отправить</span>
|
||||||
`;
|
`;
|
||||||
shareButton.addEventListener('click', async (event) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -793,22 +685,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
});
|
});
|
||||||
headActions.append(aboutButton);
|
headActions.append(aboutButton);
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
|
||||||
const editButton = document.createElement('button');
|
|
||||||
editButton.type = 'button';
|
|
||||||
editButton.className = 'secondary-btn small-btn';
|
|
||||||
editButton.textContent = '✎';
|
|
||||||
editButton.title = 'Редактировать описание';
|
|
||||||
editButton.addEventListener('click', (event) => {
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openEditDescriptionModal({
|
|
||||||
initialValue: channelData.channel.description || '',
|
|
||||||
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
headActions.append(editButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
head.append(title);
|
head.append(title);
|
||||||
head.append(owner, headActions);
|
head.append(owner, headActions);
|
||||||
|
|
||||||
@ -816,25 +692,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
actionButton.className = channelData.isOwnChannel
|
actionButton.className = channelData.isOwnChannel
|
||||||
? 'primary-btn channel-main-action'
|
? 'primary-btn channel-main-action'
|
||||||
: 'destructive-btn channel-main-action';
|
: 'destructive-btn channel-main-action';
|
||||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
feed.className = 'stack channel-feed';
|
||||||
const unreadDivider = document.createElement('div');
|
|
||||||
unreadDivider.className = 'channels-unread-divider';
|
|
||||||
unreadDivider.textContent = 'Непрочитанные сообщения';
|
|
||||||
const unreadJump = document.createElement('button');
|
|
||||||
unreadJump.type = 'button';
|
|
||||||
unreadJump.className = 'channels-unread-jump';
|
|
||||||
unreadJump.innerHTML = `
|
|
||||||
<span class="channels-unread-jump-icon" aria-hidden="true">↓</span>
|
|
||||||
<span class="channels-unread-jump-badge"></span>
|
|
||||||
`;
|
|
||||||
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
|
|
||||||
const postsByKey = new Map();
|
const postsByKey = new Map();
|
||||||
const unreadKeys = new Set();
|
|
||||||
let seenFlushInFlight = false;
|
|
||||||
let seenObserver = null;
|
|
||||||
|
|
||||||
if (channelData.posts.length) {
|
if (channelData.posts.length) {
|
||||||
channelData.posts.forEach((post) => {
|
channelData.posts.forEach((post) => {
|
||||||
@ -849,7 +711,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const key = messageRefKey(post.messageRef);
|
const key = messageRefKey(post.messageRef);
|
||||||
if (key) {
|
if (key) {
|
||||||
postsByKey.set(key, post);
|
postsByKey.set(key, post);
|
||||||
if (post.seenByMe !== true) unreadKeys.add(key);
|
|
||||||
}
|
}
|
||||||
feed.append(row);
|
feed.append(row);
|
||||||
});
|
});
|
||||||
@ -860,101 +721,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
feed.append(empty);
|
feed.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncUnreadState = () => {
|
|
||||||
unreadKeys.clear();
|
|
||||||
postsByKey.forEach((post, key) => {
|
|
||||||
if (post.seenByMe !== true) unreadKeys.add(key);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUnreadJump = () => {
|
|
||||||
const unreadCount = unreadKeys.size;
|
|
||||||
unreadJump.classList.toggle('is-visible', unreadCount > 0);
|
|
||||||
unreadJump.hidden = unreadCount <= 0;
|
|
||||||
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const mountUnreadDivider = () => {
|
|
||||||
unreadDivider.remove();
|
|
||||||
if (!unreadKeys.size) return;
|
|
||||||
const firstUnread = channelData.posts.find((post) => {
|
|
||||||
const key = messageRefKey(post.messageRef);
|
|
||||||
return key && unreadKeys.has(key);
|
|
||||||
});
|
|
||||||
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
|
|
||||||
if (!firstUnreadKey) return;
|
|
||||||
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
|
|
||||||
if (target) {
|
|
||||||
feed.insertBefore(unreadDivider, target);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const routePending = (() => {
|
|
||||||
let bucket = seenPendingByRoute.get(routeKey);
|
|
||||||
if (!bucket) {
|
|
||||||
bucket = new Set();
|
|
||||||
seenPendingByRoute.set(routeKey, bucket);
|
|
||||||
}
|
|
||||||
return bucket;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const scheduleSeenFlush = () => {
|
|
||||||
const oldTimer = seenFlushTimersByRoute.get(routeKey);
|
|
||||||
if (oldTimer) clearTimeout(oldTimer);
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
seenFlushTimersByRoute.delete(routeKey);
|
|
||||||
if (seenFlushInFlight) return;
|
|
||||||
|
|
||||||
const pendingKeys = [...routePending].filter((key) => {
|
|
||||||
const post = postsByKey.get(key);
|
|
||||||
return !!post && post.seenByMe !== true;
|
|
||||||
});
|
|
||||||
if (!pendingKeys.length) return;
|
|
||||||
|
|
||||||
const refs = pendingKeys
|
|
||||||
.map((key) => parseMessageRefKey(key))
|
|
||||||
.filter(Boolean);
|
|
||||||
if (!refs.length) return;
|
|
||||||
|
|
||||||
pendingKeys.forEach((key) => routePending.delete(key));
|
|
||||||
seenFlushInFlight = true;
|
|
||||||
try {
|
|
||||||
await handlers.onMarkSeenBatch(refs);
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const key = messageRefKey(ref);
|
|
||||||
const post = key ? postsByKey.get(key) : null;
|
|
||||||
if (post) post.seenByMe = true;
|
|
||||||
});
|
|
||||||
syncUnreadState();
|
|
||||||
mountUnreadDivider();
|
|
||||||
updateUnreadJump();
|
|
||||||
} catch (error) {
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const key = messageRefKey(ref);
|
|
||||||
if (!key) return;
|
|
||||||
const node = feed.querySelector(`[data-message-key="${key}"]`);
|
|
||||||
if (node) seenObserver?.observe(node);
|
|
||||||
});
|
|
||||||
handlers.onSeenError?.(error);
|
|
||||||
} finally {
|
|
||||||
seenFlushInFlight = false;
|
|
||||||
if (routePending.size) scheduleSeenFlush();
|
|
||||||
}
|
|
||||||
}, 220);
|
|
||||||
seenFlushTimersByRoute.set(routeKey, timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
unreadJump.addEventListener('click', () => {
|
|
||||||
const unreadPosts = channelData.posts.filter((post) => {
|
|
||||||
const key = messageRefKey(post.messageRef);
|
|
||||||
return key && unreadKeys.has(key);
|
|
||||||
});
|
|
||||||
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
|
|
||||||
const key = messageRefKey(targetPost?.messageRef);
|
|
||||||
if (!key) return;
|
|
||||||
const target = feed.querySelector(`[data-message-key="${key}"]`);
|
|
||||||
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
if (channelData.isOwnChannel) {
|
||||||
actionButton.addEventListener('click', (event) => {
|
actionButton.addEventListener('click', (event) => {
|
||||||
@ -965,7 +731,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
const backButton = document.createElement('button');
|
||||||
@ -973,57 +739,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.textContent = 'Назад к каналам';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
screen.append(head, actionButton, feed, backButton, unreadJump);
|
screen.append(head, actionButton, feed, backButton);
|
||||||
|
|
||||||
if (state.session.login && channelData.selector && channelData.posts.length) {
|
|
||||||
seenObserver = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
|
|
||||||
const key = String(entry.target?.dataset?.messageKey || '').trim();
|
|
||||||
if (!key) return;
|
|
||||||
const post = postsByKey.get(key);
|
|
||||||
if (!post || post.seenByMe === true) return;
|
|
||||||
routePending.add(key);
|
|
||||||
seenObserver?.unobserve(entry.target);
|
|
||||||
});
|
|
||||||
if (routePending.size) scheduleSeenFlush();
|
|
||||||
}, {
|
|
||||||
root: document.getElementById('app-screen') || null,
|
|
||||||
threshold: [0.6],
|
|
||||||
});
|
|
||||||
|
|
||||||
feed.querySelectorAll('[data-message-key]').forEach((node) => {
|
|
||||||
const key = String(node.dataset.messageKey || '').trim();
|
|
||||||
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
syncUnreadState();
|
|
||||||
mountUnreadDivider();
|
|
||||||
updateUnreadJump();
|
|
||||||
|
|
||||||
const firstUnreadCandidate = channelData.firstUnreadKey
|
|
||||||
|| (() => {
|
|
||||||
const first = channelData.posts.find((post) => post.seenByMe !== true);
|
|
||||||
return messageRefKey(first?.messageRef);
|
|
||||||
})();
|
|
||||||
if (firstUnreadCandidate) {
|
|
||||||
const previous = firstUnreadJumpByRoute.get(routeKey);
|
|
||||||
if (previous !== firstUnreadCandidate) {
|
|
||||||
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
|
|
||||||
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
firstUnreadJumpByRoute.delete(routeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
return () => {
|
return () => {
|
||||||
seenObserver?.disconnect();
|
// noop
|
||||||
const timer = seenFlushTimersByRoute.get(routeKey);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
seenFlushTimersByRoute.delete(routeKey);
|
|
||||||
seenPendingByRoute.delete(routeKey);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1070,7 +790,9 @@ export function render({ navigate, route }) {
|
|||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
if (!login || !storagePwd) {
|
if (!login || !storagePwd) {
|
||||||
throw new Error('Сессия недействительна. Выполните вход заново.');
|
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||||
|
navigate('login-view');
|
||||||
|
throw new Error('Для этого действия нужно войти');
|
||||||
}
|
}
|
||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
@ -1117,21 +839,6 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMarkSeenBatch = async (refs) => {
|
|
||||||
if (!Array.isArray(refs) || !refs.length) return;
|
|
||||||
const login = String(state.session.login || '').trim();
|
|
||||||
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
|
|
||||||
await authService.markChannelMessagesSeen({
|
|
||||||
login,
|
|
||||||
channel: {
|
|
||||||
ownerBlockchainName: routeSelector.ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
|
|
||||||
channelRootBlockHash: routeSelector.channelRootBlockHash,
|
|
||||||
},
|
|
||||||
messages: refs,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onShare = async (routePath) => {
|
const onShare = async (routePath) => {
|
||||||
try {
|
try {
|
||||||
const routeToShare = String(routePath || '').trim();
|
const routeToShare = String(routePath || '').trim();
|
||||||
@ -1145,7 +852,7 @@ export function render({ navigate, route }) {
|
|||||||
if (result === 'shared') showToast('Ссылка передана');
|
if (result === 'shared') showToast('Ссылка передана');
|
||||||
if (result === 'shared' || result === 'copied') softHaptic(10);
|
if (result === 'shared' || result === 'copied') softHaptic(10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1168,25 +875,6 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEditDescription = async (descriptionText) => {
|
|
||||||
const { login, storagePwd } = requireSigningSession();
|
|
||||||
const selector = routeSelector;
|
|
||||||
const param = channelDescriptionParamKey(selector);
|
|
||||||
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
|
|
||||||
|
|
||||||
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
|
|
||||||
await authService.addBlockUserParam({
|
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
param,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
|
|
||||||
softHaptic(10);
|
|
||||||
showToast('Описание канала сохранено');
|
|
||||||
rerender();
|
|
||||||
};
|
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
@ -1229,19 +917,14 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare: onShare,
|
onShare: onShare,
|
||||||
onEditDescription: async (descriptionText) => {
|
onSubscribeChannel: async (event) => {
|
||||||
try {
|
|
||||||
await onEditDescription(descriptionText);
|
|
||||||
showStatus('');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUnfollowChannel: async (event) => {
|
|
||||||
animatePress(event?.currentTarget);
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
|
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||||
|
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||||
|
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
await authService.addBlockFollowChannel({
|
await authService.addBlockFollowChannel({
|
||||||
login,
|
login,
|
||||||
@ -1249,26 +932,15 @@ export function render({ navigate, route }) {
|
|||||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||||
unfollow: true,
|
unfollow: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
softHaptic(15);
|
softHaptic(15);
|
||||||
showToast('Отписка от канала выполнена');
|
showToast('Подписка на канал выполнена');
|
||||||
navigate('channels-list');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkSeenBatch: async (refs) => {
|
|
||||||
try {
|
|
||||||
await onMarkSeenBatch(refs);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSeenError: (error) => {
|
|
||||||
showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
|
|||||||
@ -40,6 +40,11 @@ function normalizeLoginInput(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||||
|
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||||
|
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||||
|
if (ownerLogin && channelName) {
|
||||||
|
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
|
||||||
|
}
|
||||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||||
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
||||||
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
||||||
@ -406,6 +411,117 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
|
|||||||
if (inputEl) inputEl.focus();
|
if (inputEl) inputEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openChannelFinderModal({ navigate }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="channels-find-modal">
|
||||||
|
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||||
|
<h3 class="modal-title">Поиск каналов</h3>
|
||||||
|
<p class="meta-muted">Введите логин или формат login/channel</p>
|
||||||
|
<input id="channels-find-input" class="input" placeholder="login/channel" autocomplete="off" />
|
||||||
|
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||||
|
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||||
|
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inputEl = root.querySelector('#channels-find-input');
|
||||||
|
const suggestEl = root.querySelector('#channels-find-suggest');
|
||||||
|
const channelsEl = root.querySelector('#channels-find-list');
|
||||||
|
const errorEl = root.querySelector('#channels-find-error');
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
|
||||||
|
const renderButtons = (container, values, onPick) => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!values.length) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = '';
|
||||||
|
values.forEach((value) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'channel-search-item';
|
||||||
|
btn.textContent = value.label;
|
||||||
|
btn.addEventListener('click', () => onPick(value));
|
||||||
|
container.append(btn);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadChannelsForLogin = async (login, filterChannel = '') => {
|
||||||
|
const ownerLogin = normalizeLoginInput(login);
|
||||||
|
if (!ownerLogin) return;
|
||||||
|
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
|
||||||
|
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
||||||
|
const needle = String(filterChannel || '').trim().toLowerCase();
|
||||||
|
const channels = rows
|
||||||
|
.map((item) => String(item?.channel?.channelName || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((name) => !needle || name.toLowerCase().includes(needle))
|
||||||
|
.slice(0, 200)
|
||||||
|
.map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name }));
|
||||||
|
renderButtons(channelsEl, channels, (item) => {
|
||||||
|
close();
|
||||||
|
navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = createDebounced(async () => {
|
||||||
|
const raw = String(inputEl?.value || '').trim();
|
||||||
|
errorEl.textContent = '';
|
||||||
|
if (!raw) {
|
||||||
|
suggestEl.style.display = 'none';
|
||||||
|
suggestEl.innerHTML = '';
|
||||||
|
channelsEl.style.display = 'none';
|
||||||
|
channelsEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = raw.split('/');
|
||||||
|
const loginPrefix = normalizeLoginInput(parts[0] || '');
|
||||||
|
const channelFilter = String(parts[1] || '').trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (raw.includes('/')) {
|
||||||
|
suggestEl.style.display = 'none';
|
||||||
|
suggestEl.innerHTML = '';
|
||||||
|
await loadChannelsForLogin(loginPrefix, channelFilter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginPrefix.length < 2) {
|
||||||
|
suggestEl.style.display = 'none';
|
||||||
|
suggestEl.innerHTML = '';
|
||||||
|
channelsEl.style.display = 'none';
|
||||||
|
channelsEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logins = await authService.searchUsers(loginPrefix);
|
||||||
|
const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
|
||||||
|
label: login,
|
||||||
|
login,
|
||||||
|
}));
|
||||||
|
renderButtons(suggestEl, items, async (item) => {
|
||||||
|
inputEl.value = `${item.login}/`;
|
||||||
|
suggestEl.style.display = 'none';
|
||||||
|
suggestEl.innerHTML = '';
|
||||||
|
await loadChannelsForLogin(item.login, '');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
|
||||||
|
root.querySelector('#channels-find-close')?.addEventListener('click', close);
|
||||||
|
inputEl?.addEventListener('input', refresh);
|
||||||
|
if (inputEl) inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function mapMockGroups() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
@ -527,6 +643,16 @@ function toListModel(groups) {
|
|||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'meta-muted';
|
||||||
|
if (activeTab === 'subscriptions') {
|
||||||
|
text.textContent = 'Вы пока не подписаны на каналы.';
|
||||||
|
} else if (activeTab === 'my') {
|
||||||
|
text.textContent = 'У вас пока нет каналов.';
|
||||||
|
} else {
|
||||||
|
text.textContent = 'Пока нет каналов авторов.';
|
||||||
|
}
|
||||||
|
wrap.append(text);
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@ -896,14 +1022,9 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'authors') {
|
if (tab === 'authors') {
|
||||||
button.textContent = 'Подписаться на автора';
|
button.textContent = '🔍 Поиск каналов';
|
||||||
button.className = baseClass;
|
button.className = baseClass;
|
||||||
button.onclick = () => openSimpleSubscribeModal({
|
button.onclick = () => openChannelFinderModal({ navigate });
|
||||||
kind: 'user',
|
|
||||||
kindLabel: 'Подписка на автора',
|
|
||||||
submitLabel: 'Подписаться',
|
|
||||||
onSuccess: onReload,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -954,7 +1075,7 @@ export function render({ navigate }) {
|
|||||||
const notificationsState = readChannelNotificationsState();
|
const notificationsState = readChannelNotificationsState();
|
||||||
|
|
||||||
const listState = {
|
const listState = {
|
||||||
activeTab: 'my',
|
activeTab: 'subscriptions',
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
notificationsState,
|
notificationsState,
|
||||||
revealedCounters: new Set(),
|
revealedCounters: new Set(),
|
||||||
@ -1001,8 +1122,8 @@ export function render({ navigate }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
|
{ key: 'subscriptions', label: 'Каналы' },
|
||||||
{ key: 'my', label: 'Мои' },
|
{ key: 'my', label: 'Мои' },
|
||||||
{ key: 'subscriptions', label: 'Подписки' },
|
|
||||||
{ key: 'authors', label: 'Авторы' },
|
{ key: 'authors', label: 'Авторы' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -151,7 +151,7 @@ export function render({ navigate }) {
|
|||||||
let currentFields = [];
|
let currentFields = [];
|
||||||
let currentToggles = [];
|
let currentToggles = [];
|
||||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||||
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
||||||
|
|
||||||
@ -226,7 +226,9 @@ export function render({ navigate }) {
|
|||||||
login,
|
login,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
avatar: currentAvatar?.txId
|
||||||
|
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
|
: null,
|
||||||
size: 'large',
|
size: 'large',
|
||||||
className: 'profile-avatar',
|
className: 'profile-avatar',
|
||||||
}));
|
}));
|
||||||
@ -574,7 +576,7 @@ export function render({ navigate }) {
|
|||||||
currentFields = snapshot.fields;
|
currentFields = snapshot.fields;
|
||||||
currentToggles = snapshot.toggles;
|
currentToggles = snapshot.toggles;
|
||||||
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
||||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
|
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
renderFields(currentFields);
|
renderFields(currentFields);
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export function render({ navigate }) {
|
|||||||
let currentFields = [];
|
let currentFields = [];
|
||||||
let currentToggles = [];
|
let currentToggles = [];
|
||||||
let currentGender = 'unknown';
|
let currentGender = 'unknown';
|
||||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
|
||||||
function syncIdentity() {
|
function syncIdentity() {
|
||||||
if (!identityEl) return;
|
if (!identityEl) return;
|
||||||
@ -109,7 +109,9 @@ export function render({ navigate }) {
|
|||||||
login,
|
login,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
avatar: currentAvatar?.txId
|
||||||
|
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
|
: null,
|
||||||
size: 'large',
|
size: 'large',
|
||||||
className: 'profile-avatar',
|
className: 'profile-avatar',
|
||||||
}));
|
}));
|
||||||
@ -161,7 +163,7 @@ export function render({ navigate }) {
|
|||||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||||
currentGender = snapshot.gender || 'unknown';
|
currentGender = snapshot.gender || 'unknown';
|
||||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
|
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
|
|||||||
@ -130,7 +130,13 @@ export function render({ navigate }) {
|
|||||||
setAuthInfo(isLoginFlow
|
setAuthInfo(isLoginFlow
|
||||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||||
|
const nextHash = String(state.authReturnHash || '').trim();
|
||||||
|
state.authReturnHash = '';
|
||||||
|
if (nextHash.startsWith('#/')) {
|
||||||
|
navigate(nextHash.slice(2));
|
||||||
|
} else {
|
||||||
navigate('profile-view');
|
navigate('profile-view');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export function render({ navigate }) {
|
|||||||
solanaServer: state.entrySettings.solanaServer,
|
solanaServer: state.entrySettings.solanaServer,
|
||||||
shineServer: state.entrySettings.shineServer,
|
shineServer: state.entrySettings.shineServer,
|
||||||
arweaveServer: state.entrySettings.arweaveServer,
|
arweaveServer: state.entrySettings.arweaveServer,
|
||||||
|
callPreflightTimeoutMs: Number(state.entrySettings.callPreflightTimeoutMs || 6000),
|
||||||
statuses: { ...state.entrySettings.statuses },
|
statuses: { ...state.entrySettings.statuses },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,6 +109,33 @@ export function render({ navigate }) {
|
|||||||
body.append(block);
|
body.append(block);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const callSettings = document.createElement('div');
|
||||||
|
callSettings.className = 'stack';
|
||||||
|
|
||||||
|
const callTimeoutLabel = document.createElement('label');
|
||||||
|
callTimeoutLabel.className = 'field-label';
|
||||||
|
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
|
||||||
|
|
||||||
|
const callTimeoutInput = document.createElement('input');
|
||||||
|
callTimeoutInput.className = 'input';
|
||||||
|
callTimeoutInput.type = 'number';
|
||||||
|
callTimeoutInput.min = '1000';
|
||||||
|
callTimeoutInput.max = '20000';
|
||||||
|
callTimeoutInput.step = '500';
|
||||||
|
callTimeoutInput.value = String(Math.max(1000, Math.min(20000, Number(draft.callPreflightTimeoutMs) || 6000)));
|
||||||
|
callTimeoutInput.addEventListener('input', () => {
|
||||||
|
const n = Number(callTimeoutInput.value);
|
||||||
|
if (!Number.isFinite(n)) return;
|
||||||
|
draft.callPreflightTimeoutMs = Math.max(1000, Math.min(20000, Math.round(n)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const callTimeoutHint = document.createElement('p');
|
||||||
|
callTimeoutHint.className = 'meta-muted';
|
||||||
|
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
|
||||||
|
|
||||||
|
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
|
||||||
|
body.append(callSettings);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,42 @@ export function getRoute() {
|
|||||||
return { pageId, params: { channelId: dynamicId || '' } };
|
return { pageId, params: { channelId: dynamicId || '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'channel') {
|
||||||
|
// Новый короткий формат:
|
||||||
|
// #/channel/{login}/{channelName}
|
||||||
|
// #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash}
|
||||||
|
const ownerLogin = decodePart(segments[1] || '');
|
||||||
|
const channelName = decodePart(segments[2] || '');
|
||||||
|
const messageBlockNumber = segments[3] || '';
|
||||||
|
const messageBlockHash = segments[4] || '';
|
||||||
|
|
||||||
|
if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) {
|
||||||
|
return {
|
||||||
|
pageId: 'channel-thread-view',
|
||||||
|
params: {
|
||||||
|
ownerLogin,
|
||||||
|
channelName,
|
||||||
|
messageBlockNumber,
|
||||||
|
messageBlockHash,
|
||||||
|
// поддержка старого контракта страницы треда
|
||||||
|
messageBlockchainName: '',
|
||||||
|
channelOwnerBlockchainName: '',
|
||||||
|
channelRootBlockNumber: '',
|
||||||
|
channelRootBlockHash: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageId: 'channel-view',
|
||||||
|
params: {
|
||||||
|
ownerLogin,
|
||||||
|
channelName,
|
||||||
|
channelId: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'channel-thread-view') {
|
if (pageId === 'channel-thread-view') {
|
||||||
return {
|
return {
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@ -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_NAME = 'shine-ui-avatar-cache';
|
||||||
const DB_VERSION = 1;
|
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() {
|
async function getAllRecords() {
|
||||||
return withStore('readonly', (store, _tx, resolve, reject) => {
|
return withStore('readonly', (store, _tx, resolve, reject) => {
|
||||||
const req = store.getAll();
|
const req = store.getAll();
|
||||||
@ -146,22 +159,41 @@ function detectImageMime(bytes) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBlobFromCacheOrGateway({ gateway, txId }) {
|
async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = '' }) {
|
||||||
|
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
|
||||||
try {
|
try {
|
||||||
const cached = await getRecord(txId);
|
const cached = await getRecord(txId);
|
||||||
if (cached?.blob instanceof Blob) {
|
if (cached?.blob instanceof Blob) {
|
||||||
|
if (!validateSha256Hex(expected)) return cached.blob;
|
||||||
|
const cacheSha = String(cached?.sha256Hex || '').trim().toLowerCase();
|
||||||
|
if (cacheSha && cacheSha === expected) {
|
||||||
return cached.blob;
|
return cached.blob;
|
||||||
}
|
}
|
||||||
|
const ok = await verifyBlobSha256(cached.blob, expected);
|
||||||
|
if (ok) {
|
||||||
|
return cached.blob;
|
||||||
|
}
|
||||||
|
// кэш повреждён или не совпадает с ожидаемым хэшем: удаляем и перекачиваем
|
||||||
|
await deleteRecords([txId]);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore IndexedDB errors and fallback to fetch
|
// ignore IndexedDB errors and fallback to fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await fetchAvatarBlob({ gateway, txId });
|
const blob = await fetchAvatarBlob({ gateway, txId });
|
||||||
|
if (validateSha256Hex(expected)) {
|
||||||
|
const ok = await verifyBlobSha256(blob, expected);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('SHA256_MISMATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const computedSha256Hex = await sha256HexFromArrayBuffer(await blob.arrayBuffer());
|
||||||
const record = {
|
const record = {
|
||||||
txId,
|
txId,
|
||||||
blob,
|
blob,
|
||||||
contentType: String(blob.type || 'application/octet-stream'),
|
contentType: String(blob.type || 'application/octet-stream'),
|
||||||
sizeBytes: Number(blob.size || 0),
|
sizeBytes: Number(blob.size || 0),
|
||||||
|
sha256Hex: computedSha256Hex,
|
||||||
cachedAtMs: Date.now(),
|
cachedAtMs: Date.now(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@ -173,12 +205,16 @@ async function getBlobFromCacheOrGateway({ gateway, txId }) {
|
|||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedAvatarObjectUrl({ gateway, txId }) {
|
export async function getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex = '' }) {
|
||||||
const cleanTxId = String(txId || '').trim();
|
const cleanTxId = String(txId || '').trim();
|
||||||
if (!validateArweaveTxId(cleanTxId)) {
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
throw new Error('Некорректный Transaction ID Arweave');
|
throw new Error('Некорректный Transaction ID Arweave');
|
||||||
}
|
}
|
||||||
const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
|
const blob = await getBlobFromCacheOrGateway({
|
||||||
|
gateway,
|
||||||
|
txId: cleanTxId,
|
||||||
|
expectedSha256Hex,
|
||||||
|
});
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
|||||||
const MAX_AVATAR_SIDE_PX = 768;
|
const MAX_AVATAR_SIDE_PX = 768;
|
||||||
const AVATAR_QUALITY = 0.86;
|
const AVATAR_QUALITY = 0.86;
|
||||||
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||||
|
const SHA256_HEX_RE = /^[A-Fa-f0-9]{64}$/;
|
||||||
|
|
||||||
let arweaveLibPromise = null;
|
let arweaveLibPromise = null;
|
||||||
|
|
||||||
@ -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) {
|
export function validateArweaveTxId(txId) {
|
||||||
const value = String(txId || '').trim();
|
const value = String(txId || '').trim();
|
||||||
return TX_ID_RE.test(value);
|
return TX_ID_RE.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function 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();
|
const cleanTxId = String(txId || '').trim();
|
||||||
if (!validateArweaveTxId(cleanTxId)) {
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
throw new Error('Некорректный Transaction ID Arweave');
|
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) {
|
export function parseArweaveAvatarValue(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw.startsWith('AR:')) {
|
if (!raw) {
|
||||||
return { ok: false, network: '', txId: '' };
|
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)) {
|
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 }) {
|
export function buildArweaveDataUrl({ gateway, txId }) {
|
||||||
@ -209,6 +245,8 @@ export async function prepareAvatarImageFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const optimizedFile = blobToFile(blob, fileName, contentType);
|
const optimizedFile = blobToFile(blob, fileName, contentType);
|
||||||
|
const optimizedArrayBuffer = await optimizedFile.arrayBuffer();
|
||||||
|
const sha256Hex = await sha256HexFromArrayBuffer(optimizedArrayBuffer);
|
||||||
return {
|
return {
|
||||||
file: optimizedFile,
|
file: optimizedFile,
|
||||||
originalSizeBytes: Number(file.size || 0),
|
originalSizeBytes: Number(file.size || 0),
|
||||||
@ -218,6 +256,7 @@ export async function prepareAvatarImageFile(file) {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
contentType,
|
contentType,
|
||||||
|
sha256Hex,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) throw error;
|
if (error instanceof Error) throw error;
|
||||||
|
|||||||
@ -92,27 +92,6 @@ function opError(op, response) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLegacyCreateChannelFormatError(error) {
|
|
||||||
const code = String(error?.code || '').trim().toUpperCase();
|
|
||||||
const text = String(error?.message || '').toLowerCase();
|
|
||||||
if (code === 'BAD_BLOCK_FORMAT') return true;
|
|
||||||
return (
|
|
||||||
text.includes('unknown body type/version') ||
|
|
||||||
text.includes('unknown tech body type/version/subtype') ||
|
|
||||||
text.includes('bad_block_format')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelDescriptionParamKeyFromSelector(selector) {
|
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
||||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
|
||||||
const rootHash = String(selector?.channelRootBlockHash || '').trim().toLowerCase();
|
|
||||||
if (!owner || !Number.isFinite(rootNo) || rootNo < 0 || !/^[0-9a-f]{64}$/.test(rootHash)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClientInfo() {
|
function makeClientInfo() {
|
||||||
const ua = navigator.userAgent || 'unknown';
|
const ua = navigator.userAgent || 'unknown';
|
||||||
return ua.slice(0, 50);
|
return ua.slice(0, 50);
|
||||||
@ -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) {
|
function normalizeChannelDescription(value) {
|
||||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||||
const bytes = utf8Bytes(text);
|
const bytes = utf8Bytes(text);
|
||||||
@ -740,7 +699,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
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();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetChannelMessages', payload);
|
const response = await this.ws.request('GetChannelMessages', payload);
|
||||||
@ -749,7 +713,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
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();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetMessageThread', payload);
|
const response = await this.ws.request('GetMessageThread', payload);
|
||||||
@ -757,17 +726,6 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async markChannelMessagesSeen({ login, channel, messages }) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const refs = Array.isArray(messages) ? messages : [];
|
|
||||||
const payload = { channel, messages: refs };
|
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
|
||||||
|
|
||||||
const response = await this.ws.request('MarkChannelMessagesSeen', payload);
|
|
||||||
if (response.status !== 200) throw opError('MarkChannelMessagesSeen', response);
|
|
||||||
return response.payload || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||||
@ -1089,69 +1047,30 @@ export class AuthService {
|
|||||||
thisLineNumber = createdChannels.length + 1;
|
thisLineNumber = createdChannels.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitCreate = async (useV2) => {
|
const payload = await this.addBlockSigned({
|
||||||
const bodyBytes = useV2
|
login: cleanLogin,
|
||||||
? makeCreateChannelBodyV2Bytes({
|
storagePwd,
|
||||||
|
msgType: MSG_TYPE_TECH,
|
||||||
|
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||||
|
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||||
|
bodyBytes: makeCreateChannelBodyV2Bytes({
|
||||||
lineCode: 0,
|
lineCode: 0,
|
||||||
prevLineNumber,
|
prevLineNumber,
|
||||||
prevLineHashHex,
|
prevLineHashHex,
|
||||||
thisLineNumber,
|
thisLineNumber,
|
||||||
channelName: cleanChannelName,
|
channelName: cleanChannelName,
|
||||||
channelDescription: cleanChannelDescription,
|
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 selector = {
|
const selector = {
|
||||||
ownerBlockchainName: blockchainName,
|
ownerBlockchainName: blockchainName,
|
||||||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||||||
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (usedLegacyDescriptionFallback && cleanChannelDescription) {
|
|
||||||
const param = channelDescriptionParamKeyFromSelector(selector);
|
|
||||||
if (!param) {
|
|
||||||
throw new Error('Не удалось сохранить описание канала: некорректный идентификатор канала.');
|
|
||||||
}
|
|
||||||
await this.addBlockUserParam({
|
|
||||||
login: cleanLogin,
|
|
||||||
storagePwd,
|
|
||||||
param,
|
|
||||||
value: JSON.stringify({ v: cleanChannelDescription }),
|
|
||||||
});
|
|
||||||
savedDescriptionViaUserParam = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
usedLegacyDescriptionFallback,
|
|
||||||
savedDescriptionViaUserParam,
|
|
||||||
channel: {
|
channel: {
|
||||||
...selector,
|
...selector,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { addSystemChatMessage, authService } from '../state.js';
|
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js';
|
||||||
|
|
||||||
const TYPES = {
|
const TYPES = {
|
||||||
INVITE: 100,
|
INVITE: 100,
|
||||||
@ -31,6 +31,47 @@ function nowMs() {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCallPreflightTimeoutMs() {
|
||||||
|
const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000);
|
||||||
|
return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionReadyForCall() {
|
||||||
|
const wsOpen = Boolean(authService?.ws?.ws && authService.ws.ws.readyState === WebSocket.OPEN);
|
||||||
|
const hasSession = Boolean(state?.session?.isAuthorized && state?.session?.login && state?.session?.sessionId);
|
||||||
|
return wsOpen && hasSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout(promise, timeoutMs, timeoutMessage = 'timeout') {
|
||||||
|
let timerId = 0;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timerId = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timerId) window.clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSessionForCall({ timeoutMs, force = false } = {}) {
|
||||||
|
if (!force && isSessionReadyForCall()) return true;
|
||||||
|
const login = String(state?.session?.login || '').trim();
|
||||||
|
const sessionId = String(state?.session?.sessionId || '').trim();
|
||||||
|
if (!login || !sessionId) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withTimeout(authService.ws.open(), timeoutMs, 'call_preflight_ws_timeout');
|
||||||
|
const resumed = await withTimeout(authService.resumeSession(login, sessionId), timeoutMs, 'call_preflight_resume_timeout');
|
||||||
|
authorizeSession(resumed);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeCallId() {
|
function makeCallId() {
|
||||||
return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`;
|
return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`;
|
||||||
}
|
}
|
||||||
@ -853,8 +894,23 @@ async function finalizeCall(call, {
|
|||||||
|
|
||||||
call.phase = 'ended';
|
call.phase = 'ended';
|
||||||
call.statusText = 'Звонок завершён';
|
call.statusText = 'Звонок завершён';
|
||||||
|
if (String(localReasonCode || '') === 'busy') {
|
||||||
|
call.statusText = 'Пользователь занят';
|
||||||
|
}
|
||||||
notifyCallState();
|
notifyCallState();
|
||||||
|
|
||||||
|
const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0;
|
||||||
|
if (finalHoldMs > 0) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
calls.delete(call.callId);
|
||||||
|
if (activeCallId === call.callId) {
|
||||||
|
activeCallId = '';
|
||||||
|
}
|
||||||
|
notifyCallState();
|
||||||
|
}, finalHoldMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
calls.delete(call.callId);
|
calls.delete(call.callId);
|
||||||
if (activeCallId === call.callId) {
|
if (activeCallId === call.callId) {
|
||||||
activeCallId = '';
|
activeCallId = '';
|
||||||
@ -1064,6 +1120,84 @@ function ensureIncomingNotification(peerLogin) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIncomingCallPushFresh(payload) {
|
||||||
|
const expiresAtMs = Number(payload?.expiresAtMs || 0);
|
||||||
|
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0 && Date.now() > expiresAtMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
||||||
|
const callId = String(payload?.callId || '').trim();
|
||||||
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||||
|
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||||||
|
if (!callId || !fromLogin || !fromSessionId) return null;
|
||||||
|
|
||||||
|
if (activeCallId && activeCallId !== callId) {
|
||||||
|
try {
|
||||||
|
await authService.callSignalToSession({
|
||||||
|
toLogin: fromLogin,
|
||||||
|
targetSessionId: fromSessionId,
|
||||||
|
callId,
|
||||||
|
type: TYPES.DECLINE_BUSY,
|
||||||
|
data: 'busy',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let call = getCall(callId);
|
||||||
|
if (!call) {
|
||||||
|
call = {
|
||||||
|
callId,
|
||||||
|
peerLogin: fromLogin,
|
||||||
|
direction: 'in',
|
||||||
|
phase: 'incoming',
|
||||||
|
statusText: `Вам звонит ${fromLogin}`,
|
||||||
|
remoteSessionId: fromSessionId,
|
||||||
|
timers: {},
|
||||||
|
startedAtMs: nowMs(),
|
||||||
|
connectedAtMs: 0,
|
||||||
|
pc: null,
|
||||||
|
localStream: null,
|
||||||
|
audioSenders: [],
|
||||||
|
muted: false,
|
||||||
|
connectionRouteLabel: '',
|
||||||
|
reconnectInProgress: false,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
debugMode: false,
|
||||||
|
debugRunId: '',
|
||||||
|
debugRole: '',
|
||||||
|
pendingRemoteIceCandidates: [],
|
||||||
|
initialOfferInProgress: false,
|
||||||
|
initialOfferSent: false,
|
||||||
|
};
|
||||||
|
calls.set(callId, call);
|
||||||
|
} else if (!call.remoteSessionId && fromSessionId) {
|
||||||
|
call.remoteSessionId = fromSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCallId = callId;
|
||||||
|
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||||||
|
ensureIncomingNotification(fromLogin);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!call.timers.incoming20s) {
|
||||||
|
call.timers.incoming20s = setTimeout(async () => {
|
||||||
|
if (!calls.has(callId)) return;
|
||||||
|
try {
|
||||||
|
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
||||||
|
} catch {}
|
||||||
|
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
export function setCallDebugReporter(fn) {
|
export function setCallDebugReporter(fn) {
|
||||||
debugReporter = typeof fn === 'function' ? fn : null;
|
debugReporter = typeof fn === 'function' ? fn : null;
|
||||||
}
|
}
|
||||||
@ -1221,6 +1355,12 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callId = makeCallId();
|
const callId = makeCallId();
|
||||||
|
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
|
||||||
|
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
|
||||||
|
if (!preflightOk) {
|
||||||
|
throw new Error('Сервер временно недоступен');
|
||||||
|
}
|
||||||
|
|
||||||
const call = {
|
const call = {
|
||||||
callId,
|
callId,
|
||||||
peerLogin: cleanPeer,
|
peerLogin: cleanPeer,
|
||||||
@ -1266,75 +1406,26 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
try {
|
try {
|
||||||
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const text = String(error?.message || '').toUpperCase();
|
||||||
|
const isNotAuth = text.includes('NOT_AUTHENTICATED');
|
||||||
|
if (isNotAuth) {
|
||||||
|
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
||||||
|
if (recovered) {
|
||||||
|
try {
|
||||||
|
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await finalizeCall(call, { localReasonCode: 'error', debugReason: 'invite_failed:not_authenticated_after_retry' });
|
||||||
|
throw new Error('Сервер временно недоступен');
|
||||||
|
}
|
||||||
await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` });
|
await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleIncomingCallInvite(evt) {
|
export async function handleIncomingCallInvite(evt) {
|
||||||
const payload = evt?.payload || {};
|
await handleIncomingInvitePayload(evt?.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;
|
|
||||||
|
|
||||||
if (activeCallId && activeCallId !== callId) {
|
|
||||||
try {
|
|
||||||
await authService.callSignalToSession({
|
|
||||||
toLogin: fromLogin,
|
|
||||||
targetSessionId: fromSessionId,
|
|
||||||
callId,
|
|
||||||
type: TYPES.DECLINE_BUSY,
|
|
||||||
data: 'busy',
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let call = getCall(callId);
|
|
||||||
if (!call) {
|
|
||||||
call = {
|
|
||||||
callId,
|
|
||||||
peerLogin: fromLogin,
|
|
||||||
direction: 'in',
|
|
||||||
phase: 'incoming',
|
|
||||||
statusText: `Вам звонит ${fromLogin}`,
|
|
||||||
remoteSessionId: fromSessionId,
|
|
||||||
timers: {},
|
|
||||||
startedAtMs: nowMs(),
|
|
||||||
connectedAtMs: 0,
|
|
||||||
pc: null,
|
|
||||||
localStream: null,
|
|
||||||
audioSenders: [],
|
|
||||||
muted: false,
|
|
||||||
connectionRouteLabel: '',
|
|
||||||
reconnectInProgress: false,
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
debugMode: false,
|
|
||||||
debugRunId: '',
|
|
||||||
debugRole: '',
|
|
||||||
pendingRemoteIceCandidates: [],
|
|
||||||
initialOfferInProgress: false,
|
|
||||||
initialOfferSent: false,
|
|
||||||
};
|
|
||||||
calls.set(callId, call);
|
|
||||||
}
|
|
||||||
|
|
||||||
activeCallId = callId;
|
|
||||||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
|
||||||
ensureIncomingNotification(fromLogin);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendSignal(call, TYPES.RINGING, 'ringing');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
call.timers.incoming20s = setTimeout(async () => {
|
|
||||||
if (!calls.has(callId)) return;
|
|
||||||
try {
|
|
||||||
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
|
||||||
} catch {}
|
|
||||||
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
|
||||||
}, 20000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptIncomingCall() {
|
export async function acceptIncomingCall() {
|
||||||
@ -1534,3 +1625,37 @@ export async function hangupActiveCall() {
|
|||||||
notifyRemoteHangup: true,
|
notifyRemoteHangup: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleIncomingCallPush(payload = {}) {
|
||||||
|
if (!isIncomingCallPushFresh(payload)) return;
|
||||||
|
await handleIncomingInvitePayload(payload, { source: 'push' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleStopCallPush(payload = {}) {
|
||||||
|
const callId = String(payload?.callId || '').trim();
|
||||||
|
if (!callId) return;
|
||||||
|
const call = getCall(callId);
|
||||||
|
if (!call) return;
|
||||||
|
const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push';
|
||||||
|
await finalizeCall(call, {
|
||||||
|
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
|
||||||
|
debugReason: `stop_call_push:${reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCallPushAction(action, payload = {}) {
|
||||||
|
const normalized = String(action || '').trim().toLowerCase();
|
||||||
|
if (normalized !== 'accept' && normalized !== 'decline') return;
|
||||||
|
if (!isIncomingCallPushFresh(payload)) return;
|
||||||
|
const timeoutMs = resolveCallPreflightTimeoutMs();
|
||||||
|
const ok = await ensureSessionForCall({ timeoutMs, force: false });
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('Не удалось подключиться, вызов завершён');
|
||||||
|
}
|
||||||
|
await handleIncomingCallPush(payload);
|
||||||
|
if (normalized === 'accept') {
|
||||||
|
await acceptIncomingCall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await declineIncomingCall();
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
const MIN_LEN = 3;
|
const MIN_LEN = 3;
|
||||||
const MAX_LEN = 32;
|
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) {
|
export function normalizeChannelDisplayName(value) {
|
||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
return String(value).trim().replace(/\s+/g, ' ');
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeChannelDescription(value) {
|
export function normalizeChannelDescription(value) {
|
||||||
@ -16,24 +16,9 @@ export function toCanonicalChannelSlug(value) {
|
|||||||
const normalized = normalizeChannelDisplayName(value);
|
const normalized = normalizeChannelDisplayName(value);
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
|
|
||||||
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
|
const lowered = normalized.toLowerCase();
|
||||||
let out = '';
|
if (!ALLOWED_CHARS_RE.test(lowered)) return '';
|
||||||
let pendingSeparator = false;
|
return lowered;
|
||||||
|
|
||||||
for (const ch of lowered) {
|
|
||||||
if (ch === ' ' || ch === '_' || ch === '-') {
|
|
||||||
pendingSeparator = out.length > 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (pendingSeparator && out.length > 0) out += '-';
|
|
||||||
out += ch;
|
|
||||||
pendingSeparator = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.replace(/-+$/g, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateChannelDisplayName(value) {
|
export function validateChannelDisplayName(value) {
|
||||||
@ -73,7 +58,7 @@ export function channelNameErrorText(code) {
|
|||||||
case 'too_long':
|
case 'too_long':
|
||||||
return 'Название слишком длинное: максимум 32 символа.';
|
return 'Название слишком длинное: максимум 32 символа.';
|
||||||
case 'bad_chars':
|
case 'bad_chars':
|
||||||
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
|
return 'Разрешены только латиница, цифры, _ и -.';
|
||||||
case 'reserved':
|
case 'reserved':
|
||||||
return 'Название "0" зарезервировано.';
|
return 'Название "0" зарезервировано.';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -199,7 +199,12 @@ export async function loadUserProfileCard(login) {
|
|||||||
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
||||||
official: Boolean(toggles.official),
|
official: Boolean(toggles.official),
|
||||||
shine: Boolean(toggles.shine),
|
shine: Boolean(toggles.shine),
|
||||||
avatar: snapshot?.avatar?.txId ? { 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 { 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 = [
|
export const profileFieldDefs = [
|
||||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||||
@ -123,15 +128,17 @@ export async function loadProfileSnapshot(login) {
|
|||||||
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
|
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
|
||||||
const avatar = parsedAvatar.ok
|
const avatar = parsedAvatar.ok
|
||||||
? {
|
? {
|
||||||
value: buildArweaveAvatarValue(parsedAvatar.txId),
|
value: buildArweaveAvatarValue(parsedAvatar.txId, parsedAvatar.sha256Hex),
|
||||||
source: 'arweave',
|
source: 'arweave',
|
||||||
txId: parsedAvatar.txId,
|
txId: parsedAvatar.txId,
|
||||||
|
sha256Hex: parsedAvatar.sha256Hex || '',
|
||||||
timeMs: latestAvatar?.timeMs || 0,
|
timeMs: latestAvatar?.timeMs || 0,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
value: '',
|
value: '',
|
||||||
source: '',
|
source: '',
|
||||||
txId: '',
|
txId: '',
|
||||||
|
sha256Hex: '',
|
||||||
timeMs: latestAvatar?.timeMs || 0,
|
timeMs: latestAvatar?.timeMs || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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 cleanTxId = String(txId || '').trim();
|
||||||
|
const cleanSha = String(sha256Hex || '').trim().toLowerCase();
|
||||||
if (!validateArweaveTxId(cleanTxId)) {
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
throw new Error('Некорректный Transaction ID Arweave');
|
throw new Error('Некорректный Transaction ID Arweave');
|
||||||
}
|
}
|
||||||
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_SOLANA_SERVER = 'https://api.devnet.solana.com';
|
||||||
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
|
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
|
||||||
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
||||||
|
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
|
||||||
|
|
||||||
function loadStoredSession() {
|
function loadStoredSession() {
|
||||||
try {
|
try {
|
||||||
@ -146,6 +147,7 @@ function persistEntrySettings(settings) {
|
|||||||
solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER),
|
solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER),
|
||||||
shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER),
|
shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER),
|
||||||
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||||
|
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||||
statuses: {
|
statuses: {
|
||||||
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
||||||
shineServer: String(settings?.statuses?.shineServer || 'idle'),
|
shineServer: String(settings?.statuses?.shineServer || 'idle'),
|
||||||
@ -208,6 +210,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER),
|
solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER),
|
||||||
shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer),
|
shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer),
|
||||||
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||||
|
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||||
statuses: {
|
statuses: {
|
||||||
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
||||||
shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),
|
shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),
|
||||||
@ -249,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
error: '',
|
error: '',
|
||||||
info: '',
|
info: '',
|
||||||
},
|
},
|
||||||
|
authReturnHash: '',
|
||||||
sessions: [],
|
sessions: [],
|
||||||
channelsFeed: null,
|
channelsFeed: null,
|
||||||
channelsIndex: {},
|
channelsIndex: {},
|
||||||
|
|||||||
@ -360,35 +360,7 @@ public final class DatabaseInitializer {
|
|||||||
ON message_stats (to_login);
|
ON message_stats (to_login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем)
|
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_views_state (
|
|
||||||
viewer_login TEXT NOT NULL,
|
|
||||||
to_bch_name TEXT NOT NULL,
|
|
||||||
to_block_number INTEGER NOT NULL,
|
|
||||||
to_block_hash BLOB NOT NULL,
|
|
||||||
first_seen_at_ms INTEGER NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (
|
|
||||||
viewer_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash
|
|
||||||
)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
|
|
||||||
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
|
|
||||||
ON message_views_state (viewer_login, to_bch_name);
|
|
||||||
""");
|
|
||||||
|
|
||||||
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||||
from_login TEXT NOT NULL,
|
from_login TEXT NOT NULL,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
private static volatile SqliteDbController instance;
|
||||||
private static final int LATEST_SCHEMA_VERSION = DatabaseInitializer.SCHEMA_VERSION_1;
|
private static final int LATEST_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -84,6 +84,7 @@ public final class SqliteDbController {
|
|||||||
private void applyMigration(int targetVersion) {
|
private void applyMigration(int targetVersion) {
|
||||||
switch (targetVersion) {
|
switch (targetVersion) {
|
||||||
case 1 -> migrateToV1();
|
case 1 -> migrateToV1();
|
||||||
|
case 2 -> migrateToV2();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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() {
|
private int getCurrentSchemaVersion() {
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
||||||
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
||||||
@ -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 {
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
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 {
|
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
|
|||||||
@ -7,13 +7,13 @@ public final class ChannelNameRules {
|
|||||||
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
||||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||||
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||||
|
|
||||||
private ChannelNameRules() {}
|
private ChannelNameRules() {}
|
||||||
|
|
||||||
public static String normalizeDisplayName(String value) {
|
public static String normalizeDisplayName(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String requireValidDisplayNameForCreate(String rawName) {
|
public static String requireValidDisplayNameForCreate(String rawName) {
|
||||||
@ -40,45 +40,10 @@ public final class ChannelNameRules {
|
|||||||
throw new IllegalArgumentException("channelName is blank");
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
|
String lowered = normalized.toLowerCase(Locale.ROOT);
|
||||||
StringBuilder slug = new StringBuilder(lowered.length());
|
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) {
|
||||||
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");
|
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||||
}
|
}
|
||||||
|
return lowered;
|
||||||
if (pendingSeparator && slug.length() > 0) {
|
|
||||||
slug.append('-');
|
|
||||||
}
|
|
||||||
pendingSeparator = false;
|
|
||||||
slug.appendCodePoint(cp);
|
|
||||||
}
|
|
||||||
|
|
||||||
int len = slug.length();
|
|
||||||
if (len > 0 && slug.charAt(len - 1) == '-') {
|
|
||||||
slug.deleteCharAt(len - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug.length() == 0) {
|
|
||||||
throw new IllegalArgumentException("channelName canonical slug is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
return slug.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isLatinOrCyrillicOrDigit(int cp) {
|
|
||||||
if (Character.isDigit(cp)) return true;
|
|
||||||
Character.UnicodeScript script = Character.UnicodeScript.of(cp);
|
|
||||||
return script == Character.UnicodeScript.LATIN || script == Character.UnicodeScript.CYRILLIC;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler;
|
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
@ -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_SendTestWebPush_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
@ -133,7 +129,6 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
|
|
||||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
@ -145,7 +140,6 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()),
|
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||||
@ -190,7 +184,6 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class),
|
|
||||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
@ -202,7 +195,6 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class),
|
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||||
|
|||||||
@ -142,6 +142,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
||||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||||
|
case "channel_zero_writes_disabled" -> "Запись в канал 0 временно отключена";
|
||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
@ -337,6 +338,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
prevLineHash32 = bl.prevLineBlockHash32();
|
prevLineHash32 = bl.prevLineBlockHash32();
|
||||||
thisLineNumber = bl.lineSeq();
|
thisLineNumber = bl.lineSeq();
|
||||||
|
|
||||||
|
// Канал 0 сохраняем как технический root, но публикации в него пока не принимаем.
|
||||||
|
// Это правило защищает от "случайных" постов в дефолтный канал.
|
||||||
|
int msgType = block.type & 0xFFFF;
|
||||||
|
int msgSubType = block.subType & 0xFFFF;
|
||||||
|
if (msgType == 1
|
||||||
|
&& msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|
&& lineCode != null
|
||||||
|
&& lineCode == 0) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_zero_writes_disabled", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
|
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
|
||||||
if (prevLineNumber != null && prevLineNumber == -1) {
|
if (prevLineNumber != null && prevLineNumber == -1) {
|
||||||
lineCode = null;
|
lineCode = null;
|
||||||
|
|||||||
@ -33,7 +33,7 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) return "0";
|
if (rootNumber == 0) return "news";
|
||||||
|
|
||||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@ -212,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 {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) return "";
|
if (rootNumber == 0) return "";
|
||||||
|
|
||||||
|
|||||||
@ -108,22 +108,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash));
|
|
||||||
item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
|
||||||
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.setMessages(items);
|
resp.setMessages(items);
|
||||||
int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode);
|
|
||||||
resp.setUnreadCount(unreadCount);
|
|
||||||
ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode);
|
|
||||||
if (firstUnread != null) {
|
|
||||||
Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef();
|
|
||||||
firstUnreadRef.setBlockNumber(firstUnread.blockNumber);
|
|
||||||
firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash));
|
|
||||||
resp.setFirstUnreadMessageRef(firstUnreadRef);
|
|
||||||
}
|
|
||||||
return resp;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("GetChannelMessages failed", e);
|
log.error("GetChannelMessages failed", e);
|
||||||
|
|||||||
@ -178,9 +178,6 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash));
|
|
||||||
node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
|
||||||
|
|
||||||
if (row.lineCode != null && row.lineCode >= 0) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
ci.setOwnerBlockchainName(row.bchName);
|
ci.setOwnerBlockchainName(row.bchName);
|
||||||
@ -229,4 +226,3 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int msgSubType;
|
int msgSubType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||||
row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber));
|
row.setUnreadCount(0);
|
||||||
|
|
||||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
if (lastPost != null) {
|
if (lastPost != null) {
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import java.util.List;
|
|||||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||||
private Channel channel;
|
private Channel channel;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
private int unreadCount;
|
|
||||||
private BlockRef firstUnreadMessageRef;
|
|
||||||
|
|
||||||
public Channel getChannel() { return channel; }
|
public Channel getChannel() { return channel; }
|
||||||
public void setChannel(Channel channel) { this.channel = channel; }
|
public void setChannel(Channel channel) { this.channel = channel; }
|
||||||
@ -17,11 +15,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
public int getUnreadCount() { return unreadCount; }
|
|
||||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
|
||||||
|
|
||||||
public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; }
|
|
||||||
public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; }
|
|
||||||
|
|
||||||
public static class Channel {
|
public static class Channel {
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
@ -55,8 +48,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private int likesCount;
|
private int likesCount;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
private int viewCount;
|
|
||||||
private boolean seenByMe;
|
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
@ -84,12 +75,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||||
|
|
||||||
public int getViewCount() { return viewCount; }
|
|
||||||
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
|
|
||||||
|
|
||||||
public boolean isSeenByMe() { return seenByMe; }
|
|
||||||
public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; }
|
|
||||||
|
|
||||||
public int getVersionsTotal() { return versionsTotal; }
|
public int getVersionsTotal() { return versionsTotal; }
|
||||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import java.util.regex.Pattern;
|
|||||||
|
|
||||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||||
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
|
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
|
||||||
|
private static final Pattern AVATAR_AR_TOKEN_PATTERN = Pattern.compile("(?:^|,)\\s*AR:([A-Za-z0-9_-]{43})\\s*(?:,|$)");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
@ -201,8 +202,18 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private String extractArAvatarTxId(String rawValue) {
|
private String extractArAvatarTxId(String rawValue) {
|
||||||
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
|
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
|
||||||
if (!value.startsWith("AR:")) return null;
|
if (value.isEmpty()) return null;
|
||||||
String txId = value.substring(3).trim();
|
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;
|
if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null;
|
||||||
return txId;
|
return txId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,12 +51,18 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
String turnUsername = "";
|
String turnUsername = "";
|
||||||
String turnPassword = "";
|
String turnPassword = "";
|
||||||
|
List<Net_GetCallIceConfig_Response.TurnServerConfig> turnServers = buildTurnServers(ctx, nowMs, ttlSec);
|
||||||
|
|
||||||
String sharedSecret = readStr("call.ice.turn.sharedSecret", "");
|
String sharedSecret = readStr("call.ice.turn.sharedSecret", "");
|
||||||
String staticUsername = readStr("call.ice.turn.username", "");
|
String staticUsername = readStr("call.ice.turn.username", "");
|
||||||
String staticPassword = readStr("call.ice.turn.password", "");
|
String staticPassword = readStr("call.ice.turn.password", "");
|
||||||
|
|
||||||
if (!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()) {
|
if (!sharedSecret.isBlank()) {
|
||||||
long expiresEpochSec = nowMs / 1000L + ttlSec;
|
long expiresEpochSec = nowMs / 1000L + ttlSec;
|
||||||
expiresAtMs = expiresEpochSec * 1000L;
|
expiresAtMs = expiresEpochSec * 1000L;
|
||||||
@ -78,6 +84,7 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
|||||||
resp.setTurnUrls(turnUrls);
|
resp.setTurnUrls(turnUrls);
|
||||||
resp.setTurnUsername(turnUsername);
|
resp.setTurnUsername(turnUsername);
|
||||||
resp.setTurnPassword(turnPassword);
|
resp.setTurnPassword(turnPassword);
|
||||||
|
resp.setTurnServers(turnServers);
|
||||||
resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank());
|
resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank());
|
||||||
resp.setGeneratedAtMs(nowMs);
|
resp.setGeneratedAtMs(nowMs);
|
||||||
resp.setExpiresAtMs(expiresAtMs);
|
resp.setExpiresAtMs(expiresAtMs);
|
||||||
@ -85,6 +92,40 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<Net_GetCallIceConfig_Response.TurnServerConfig> buildTurnServers(ConnectionContext ctx, long nowMs, int ttlSec) {
|
||||||
|
List<Net_GetCallIceConfig_Response.TurnServerConfig> out = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 16; i++) {
|
||||||
|
String base = "call.ice.turn.servers." + i + ".";
|
||||||
|
List<String> urls = parseUrls(readStr(base + "urls", ""));
|
||||||
|
if (urls.isEmpty()) continue;
|
||||||
|
|
||||||
|
String id = readStr(base + "id", "turn-" + i);
|
||||||
|
String sharedSecret = readStr(base + "sharedSecret", "");
|
||||||
|
String staticUsername = readStr(base + "username", "");
|
||||||
|
String staticPassword = readStr(base + "password", "");
|
||||||
|
String username = "";
|
||||||
|
String password = "";
|
||||||
|
if (!sharedSecret.isBlank()) {
|
||||||
|
long expiresEpochSec = nowMs / 1000L + ttlSec;
|
||||||
|
String prefix = readStr("call.ice.turn.userPrefix", "shine");
|
||||||
|
String safeLogin = sanitizeLogin(ctx.getLogin());
|
||||||
|
username = expiresEpochSec + ":" + prefix + "_" + safeLogin;
|
||||||
|
password = makeTurnRestPassword(sharedSecret, username);
|
||||||
|
} else if (!staticUsername.isBlank() && !staticPassword.isBlank()) {
|
||||||
|
username = staticUsername;
|
||||||
|
password = staticPassword;
|
||||||
|
}
|
||||||
|
if (username.isBlank() || password.isBlank()) continue;
|
||||||
|
Net_GetCallIceConfig_Response.TurnServerConfig item = new Net_GetCallIceConfig_Response.TurnServerConfig();
|
||||||
|
item.setId(id);
|
||||||
|
item.setUrls(urls);
|
||||||
|
item.setUsername(username);
|
||||||
|
item.setPassword(password);
|
||||||
|
out.add(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private static int readInt(String key, int fallback) {
|
private static int readInt(String key, int fallback) {
|
||||||
String value = CONFIG.getParam(key);
|
String value = CONFIG.getParam(key);
|
||||||
if (value == null || value.isBlank()) return fallback;
|
if (value == null || value.isBlank()) return fallback;
|
||||||
@ -146,4 +187,3 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,27 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class Net_GetCallIceConfig_Response extends Net_Response {
|
public class Net_GetCallIceConfig_Response extends Net_Response {
|
||||||
|
public static class TurnServerConfig {
|
||||||
|
private String id = "";
|
||||||
|
private List<String> urls = new ArrayList<>();
|
||||||
|
private String username = "";
|
||||||
|
private String password = "";
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
public List<String> getUrls() { return urls; }
|
||||||
|
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> stunUrls = new ArrayList<>();
|
private List<String> stunUrls = new ArrayList<>();
|
||||||
private List<String> turnUrls = new ArrayList<>();
|
private List<String> turnUrls = new ArrayList<>();
|
||||||
private String turnUsername = "";
|
private String turnUsername = "";
|
||||||
private String turnPassword = "";
|
private String turnPassword = "";
|
||||||
|
private List<TurnServerConfig> turnServers = new ArrayList<>();
|
||||||
private boolean turnEnabled;
|
private boolean turnEnabled;
|
||||||
private long generatedAtMs;
|
private long generatedAtMs;
|
||||||
private long expiresAtMs;
|
private long expiresAtMs;
|
||||||
@ -27,6 +44,9 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
|
|||||||
public String getTurnPassword() { return turnPassword; }
|
public String getTurnPassword() { return turnPassword; }
|
||||||
public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; }
|
public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; }
|
||||||
|
|
||||||
|
public List<TurnServerConfig> getTurnServers() { return turnServers; }
|
||||||
|
public void setTurnServers(List<TurnServerConfig> turnServers) { this.turnServers = turnServers; }
|
||||||
|
|
||||||
public boolean isTurnEnabled() { return turnEnabled; }
|
public boolean isTurnEnabled() { return turnEnabled; }
|
||||||
public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; }
|
public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; }
|
||||||
|
|
||||||
@ -39,4 +59,3 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
|
|||||||
public int getTtlSec() { return ttlSec; }
|
public int getTtlSec() { return ttlSec; }
|
||||||
public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; }
|
public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Response;
|
||||||
import server.logic.ws_protocol.JSON.push.FcmPushSender;
|
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.PushTokensDAO;
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
import shine.db.entities.PushTokenEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@ -26,6 +26,7 @@ import java.util.Set;
|
|||||||
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int TYPE_INVITE = 100;
|
private static final int TYPE_INVITE = 100;
|
||||||
|
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
@ -49,12 +50,13 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
String from = ctx.getLogin();
|
String from = ctx.getLogin();
|
||||||
String to = targetUser.getLogin();
|
String to = targetUser.getLogin();
|
||||||
long timeMs = System.currentTimeMillis();
|
long timeMs = System.currentTimeMillis();
|
||||||
|
long expiresAtMs = timeMs + PUSH_CALL_TTL_MS;
|
||||||
|
|
||||||
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
||||||
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
|
List<ActiveSessionEntry> allTargetSessions = ActiveSessionsDAO.getInstance().getByLogin(to);
|
||||||
|
|
||||||
int wsDelivered = 0;
|
int wsDelivered = 0;
|
||||||
int fcmDelivered = 0;
|
int webPushDelivered = 0;
|
||||||
Set<String> activeSessionIds = new HashSet<>();
|
Set<String> activeSessionIds = new HashSet<>();
|
||||||
|
|
||||||
for (ConnectionContext targetCtx : activeSessions) {
|
for (ConnectionContext targetCtx : activeSessions) {
|
||||||
@ -74,14 +76,31 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
if (sent) wsDelivered++;
|
if (sent) wsDelivered++;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (PushTokenEntry token : tokens) {
|
for (ActiveSessionEntry session : allTargetSessions) {
|
||||||
boolean pushed = FcmPushSender.sendNotification(
|
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||||
token.getToken(),
|
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
||||||
"Входящий звонок",
|
continue;
|
||||||
from + " пытается дозвониться",
|
}
|
||||||
callId
|
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();
|
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.setStatus(WireCodes.Status.OK);
|
||||||
resp.setCallId(callId);
|
resp.setCallId(callId);
|
||||||
resp.setDeliveredWsSessions(wsDelivered);
|
resp.setDeliveredWsSessions(wsDelivered);
|
||||||
resp.setDeliveredFcmSessions(fcmDelivered);
|
resp.setDeliveredFcmSessions(webPushDelivered);
|
||||||
|
resp.setDeliveredWebPushSessions(webPushDelivered);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jsonEscape(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == '\\') out.append("\\\\");
|
||||||
|
else if (c == '"') out.append("\\\"");
|
||||||
|
else if (c == '\n') out.append("\\n");
|
||||||
|
else if (c == '\r') out.append("\\r");
|
||||||
|
else if (c == '\t') out.append("\\t");
|
||||||
|
else out.append(c);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int TYPE_ACCEPT = 120;
|
private static final int TYPE_ACCEPT = 120;
|
||||||
|
private static final int TYPE_DECLINE_BUSY = 130;
|
||||||
|
private static final int TYPE_TIMEOUT = 140;
|
||||||
private static final int TYPE_HANGUP = 150;
|
private static final int TYPE_HANGUP = 150;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -72,7 +79,34 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
|||||||
boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
|
boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload);
|
||||||
|
|
||||||
if (type == TYPE_ACCEPT) {
|
if (type == TYPE_ACCEPT) {
|
||||||
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();
|
Net_CallSignalToSession_Response resp = new Net_CallSignalToSession_Response();
|
||||||
@ -83,31 +117,81 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void notifyAcceptedOnOtherSessions(ConnectionContext accepterCtx, String callId) {
|
private void notifyStopOnOtherSessions(
|
||||||
if (accepterCtx == null) return;
|
String targetLogin,
|
||||||
String login = accepterCtx.getLogin();
|
String excludeSessionId,
|
||||||
String acceptedSessionId = accepterCtx.getSessionId();
|
String fromLogin,
|
||||||
if (login == null || login.isBlank() || acceptedSessionId == null || acceptedSessionId.isBlank() || callId == null || callId.isBlank()) {
|
String fromSessionId,
|
||||||
|
String callId,
|
||||||
|
String reason
|
||||||
|
) throws Exception {
|
||||||
|
if (isBlank(targetLogin) || isBlank(callId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(login);
|
Set<String> onlineSessionIds = new HashSet<>();
|
||||||
|
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
|
||||||
for (ConnectionContext siblingCtx : sameUserSessions) {
|
for (ConnectionContext siblingCtx : sameUserSessions) {
|
||||||
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
|
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
|
||||||
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");
|
String siblingEventId = NetIdGenerator.eventId("evt");
|
||||||
ObjectNode siblingPayload = MAPPER.createObjectNode();
|
ObjectNode siblingPayload = MAPPER.createObjectNode();
|
||||||
siblingPayload.put("eventId", siblingEventId);
|
siblingPayload.put("eventId", siblingEventId);
|
||||||
siblingPayload.put("fromLogin", login);
|
siblingPayload.put("fromLogin", fromLogin);
|
||||||
siblingPayload.put("fromSessionId", acceptedSessionId);
|
siblingPayload.put("fromSessionId", fromSessionId);
|
||||||
siblingPayload.put("toLogin", login);
|
siblingPayload.put("toLogin", targetLogin);
|
||||||
siblingPayload.put("callId", callId);
|
siblingPayload.put("callId", callId);
|
||||||
siblingPayload.put("type", TYPE_HANGUP);
|
siblingPayload.put("type", TYPE_HANGUP);
|
||||||
siblingPayload.put("data", "accepted_on_other_device");
|
siblingPayload.put("data", reason);
|
||||||
siblingPayload.put("timeMs", System.currentTimeMillis());
|
siblingPayload.put("timeMs", System.currentTimeMillis());
|
||||||
|
|
||||||
WsEventSender.sendEvent(siblingCtx, "IncomingCallSignal", siblingEventId, siblingPayload);
|
WsEventSender.sendEvent(siblingCtx, "IncomingCallSignal", siblingEventId, siblingPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||||
|
long sentAtMs = System.currentTimeMillis();
|
||||||
|
for (ActiveSessionEntry session : persistedSessions) {
|
||||||
|
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||||
|
if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue;
|
||||||
|
if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue;
|
||||||
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String pushPayload = "{\"kind\":\"stop_call\""
|
||||||
|
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
|
||||||
|
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
|
||||||
|
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
|
||||||
|
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
|
||||||
|
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
|
||||||
|
+ ",\"sentAtMs\":" + sentAtMs
|
||||||
|
+ "}";
|
||||||
|
WebPushSender.sendBase64Payload(
|
||||||
|
session.getPushEndpoint(),
|
||||||
|
session.getPushP256dhKey(),
|
||||||
|
session.getPushAuthKey(),
|
||||||
|
pushPayload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jsonEscape(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == '\\') out.append("\\\\");
|
||||||
|
else if (c == '"') out.append("\\\"");
|
||||||
|
else if (c == '\n') out.append("\\n");
|
||||||
|
else if (c == '\r') out.append("\\r");
|
||||||
|
else if (c == '\t') out.append("\\t");
|
||||||
|
else out.append(c);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 String callId;
|
||||||
private int deliveredWsSessions;
|
private int deliveredWsSessions;
|
||||||
private int deliveredFcmSessions;
|
private int deliveredFcmSessions;
|
||||||
|
private int deliveredWebPushSessions;
|
||||||
|
|
||||||
public String getCallId() { return callId; }
|
public String getCallId() { return callId; }
|
||||||
public void setCallId(String callId) { this.callId = callId; }
|
public void setCallId(String callId) { this.callId = callId; }
|
||||||
@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Response extends Net_Response {
|
|||||||
|
|
||||||
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
|
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
|
||||||
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
|
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
|
||||||
|
|
||||||
|
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||||
|
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,16 +9,18 @@ import java.util.jar.JarFile;
|
|||||||
public class IT_DeployRestartNoCleanNoTestsMain {
|
public class IT_DeployRestartNoCleanNoTestsMain {
|
||||||
|
|
||||||
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
||||||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "shineup.me");
|
||||||
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
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 REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
||||||
|
|
||||||
private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
|
private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
|
||||||
private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
|
private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
ensureSudoNoPasswordOrThrow();
|
||||||
|
|
||||||
// 1) stop service на сервере
|
// 1) stop service на сервере
|
||||||
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
|
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
|
||||||
|
|
||||||
@ -40,6 +42,15 @@ public class IT_DeployRestartNoCleanNoTestsMain {
|
|||||||
System.out.println("deploy_no_clean_no_tests_done");
|
System.out.println("deploy_no_clean_no_tests_done");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSudoNoPasswordOrThrow() {
|
||||||
|
int code = ssh("sudo -n systemctl status " + SERVICE_NAME + " >/dev/null 2>&1");
|
||||||
|
if (code == 0) return;
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST
|
||||||
|
+ ". Configure NOPASSWD for 'systemctl ... " + SERVICE_NAME + "' or run deploy with privileged user."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static void waitRemotePort7070() {
|
private static void waitRemotePort7070() {
|
||||||
for (int i = 0; i < 50; i++) {
|
for (int i = 0; i < 50; i++) {
|
||||||
int code = ssh("ss -ltnp | grep -q ':7070'");
|
int code = ssh("ss -ltnp | grep -q ':7070'");
|
||||||
|
|||||||
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-доставку в активные сессии целевых пользователей.
|
5. Если пара реально добавилась в БД, сервер запускает realtime-доставку в активные сессии целевых пользователей.
|
||||||
6. Если это дубль, дальнейшая доставка не выполняется (повтор не разгоняется).
|
6. Если это дубль, дальнейшая доставка не выполняется (повтор не разгоняется).
|
||||||
|
|
||||||
|
## Как сообщение доходит до клиента (WS + WebPush)
|
||||||
|
1. После успешной записи сервер пытается доставить сообщение во все активные сессии `targetLogin`.
|
||||||
|
2. Для каждой сессии сервер сначала создаёт/проверяет запись доставки в `signed_message_session_delivery` (pending).
|
||||||
|
3. Если сессия онлайн, сервер шлёт `SignedMessageArrived` по WebSocket.
|
||||||
|
4. Если сессия офлайн и тип сообщения входящий текст (`TYPE_INCOMING_TEXT`), сервер пробует WebPush (если у сессии сохранены `endpoint/p256dh/auth`).
|
||||||
|
5. При следующем логине/переподключении сервер дочитывает pending-сообщения и повторно отправляет их в эту сессию как backlog.
|
||||||
|
|
||||||
|
## Подтверждение доставки (ACK)
|
||||||
|
- Используется метод `AckSessionDelivery`.
|
||||||
|
- Клиент отправляет ACK после обработки `SignedMessageArrived` с `messageKey`.
|
||||||
|
- Сервер помечает `(messageKey, sessionId)` как `delivered=1`, и это сообщение перестаёт быть pending для этой сессии.
|
||||||
|
|
||||||
|
### Важно про безопасность ACK
|
||||||
|
- `AckSessionDelivery` требует авторизованную WS-сессию (`ctx.isAuthenticatedUser()`).
|
||||||
|
- `sessionId` берётся сервером из текущего `ConnectionContext`, а не из payload запроса.
|
||||||
|
- Поэтому подтвердить доставку «просто зная messageKey/sessionId» без авторизованной сессии нельзя.
|
||||||
|
|
||||||
## Почему допускаются дубли сети
|
## Почему допускаются дубли сети
|
||||||
- В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения.
|
- В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения.
|
||||||
- Дедупликация делается на уровне БД по ключам записи.
|
- Дедупликация делается на уровне БД по ключам записи.
|
||||||
|
|||||||
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