Compare commits

..
Author SHA256 Message Date
AidarKC 4b0a934e51 Ограничил access servers до двух в UI и сервере 2026-08-11 17:21:07 +04:00
AidarKC ee5d86003b UI: уменьшение splash logo и правки diary preview 2026-08-11 17:03:46 +04:00
AidarKC bc11b60147 UI: правки клавиатуры и дневника 2026-08-11 16:26:31 +04:00
AidarKC 801a4697ea UI: общий верхний и нижний тулбар 2026-08-11 15:22:10 +04:00
AidarKC 628a970e6f Доработать UI звонков и нижний тулбар 2026-08-11 13:29:29 +04:00
AidarKC 566ea942dc Починить включение видео у принимающей стороны 2026-08-11 12:49:35 +04:00
AidarKC e047c2d54e Подготовить обычный звонок к позднему апгрейду в видео 2026-08-11 12:43:17 +04:00
AidarKC 3fdd3d22e2 Переименовать режимы видеозвонков в меню чата 2026-08-10 18:25:10 +04:00
AidarKC 08e0fa9206 Вернуть стабильный flow видеозвонка 2026-08-10 18:20:35 +04:00
AidarKC ed9e6ce676 Исправить отображение версий в дневнике и тредах 2026-08-10 17:55:27 +04:00
AidarKC a7bf07130e Добавить режимы аудио и видеозвонка в чат 2026-08-10 15:22:43 +04:00
AidarKC 7c31662698 Зафиксировать текущую схему видеозвонка 2026-08-10 15:16:57 +04:00
AidarKC 15df06a0bc Оптимизировать видеозаглушку звонка 2026-08-10 15:02:35 +04:00
AidarKC 47b5f8db7b Починить двустороннее видео в звонках 2026-08-10 15:00:44 +04:00
AidarKC 0e763987aa Добавить базовую поддержку видеозвонка 2026-08-10 13:53:04 +04:00
AidarKC 37e8ea27d0 Доработать оглавление канала и отображение дневника 2026-08-10 03:05:23 +04:00
AidarKC 67d63256c2 Добавить дневник действий и статусы материалов 2026-08-10 01:34:17 +04:00
AidarKC ee185cf20a Добавить рейтинг и типы материалов в каналах 2026-08-10 01:16:10 +04:00
AidarKC 3552e05e4c UI: обновить экран канала и треда 2026-08-09 21:26:08 +04:00
AidarKC 735bb55b78 Убрать отправку DM по Enter 2026-08-09 21:02:44 +04:00
AidarKC 60f8a50a97 Подправить иконку звонка в чате 2026-08-09 20:57:04 +04:00
AidarKC ffd01814b0 Уточнить уведомления и карточку звонка 2026-08-09 20:54:52 +04:00
AidarKC c511910b8f Перевести звонки на SendSignal с client key 2026-08-09 20:09:54 +04:00
AidarKC 5c38f8f0d8 Deploy: обновить backup-схему и игнор архивов 2026-08-09 19:56:25 +04:00
AidarKC d512442325 Сервер: добавить новые TEXT типы и STATUS_ACTION 2026-08-09 18:59:59 +04:00
aidar 43f54c90d2 Улучшить окно создания канала и окно загрузки в Arweave
Улучшенно окно создания канала и окно загрузки в Arweave/Turbo, исправленны мелкие баги UI и маршрутов SHiNE.
2026-08-07 11:16:44 +00:00
AidarKC 57a99b478a UI: улучшить создание канала и окно загрузки 2026-08-07 15:15:56 +04:00
AidarKC 3f402fbde7 UI: обновить ссылки SHiNE и форму канала 2026-08-07 14:38:27 +04:00
AidarKC 98140c1f71 UI: создание канала и ошибки Solana 2026-08-07 14:20:18 +04:00
aidar f00de85e38 Merge pull request 'Обновить запуск UI и упростить регистрацию' (#1) from milana-ui-redesign into main
добавлен стартовый экран UI до первого успешного подключения;
упрощена форма регистрации и удалён временный режим промокодов;
обновлены FAQ и документация переноса экранов в Figma;
версия клиента увеличена до 1.5.1.
2026-08-07 09:45:46 +00:00
malvviiina b686b12c6a Обновить запуск UI и упростить регистрацию 2026-08-06 22:53:44 +03:00
AidarKC e4b903b13c вкладки в каналах 2026-08-06 22:45:15 +04:00
AidarKC 72f576a70e Версия UI 1.5.0 для merge в main 2026-08-06 22:44:11 +04:00
AidarKC c4f2c7be63 UI вложений и черновики форматов каналов 2026-08-06 12:39:33 +04:00
AidarKC 75b75fd223 Добавить Turbo-загрузку и превью для вложений 2026-08-04 19:09:54 +04:00
AidarKC 391b18a5ac Deploy: задать production VAPID keys для UI 2026-08-03 08:34:34 +04:00
AidarKC 8de0f8e87c UI: писать SHiNE в ссылках каналов 2026-08-01 10:12:11 +04:00
AidarKC adb0b76559 UI: запоминать Arweave-кошелёк загрузчика 2026-08-01 09:59:25 +04:00
AidarKC b6df25c02c UI: скрыть неактивные стрелки вложений 2026-08-01 09:47:09 +04:00
AidarKC 620186db1c Документация: уточнить AddBlock для вложений и профиля каналов 2026-08-01 09:43:51 +04:00
AidarKC 21fac8046e UI: сделать плитки каналов почти во всю ширину 2026-08-01 02:59:15 +04:00
AidarKC de7c35f587 UI: расширить плитки каналов 2026-08-01 02:52:25 +04:00
AidarKC a23c979045 UI: обрезать аватары каналов кругом 2026-08-01 02:42:11 +04:00
AidarKC 13da7287b1 UI: скрыть аватары из истории вложений 2026-08-01 02:40:08 +04:00
AidarKC e8398df9fc Каналы: исправить редактирование профиля 2026-08-01 02:29:11 +04:00
AidarKC 8303682bd7 Каналы: создавать профиль одним блоком 2026-08-01 02:19:29 +04:00
AidarKC 8125d1a124 Каналы: исправить загрузку meta-событий 2026-08-01 02:04:48 +04:00
AidarKC 110ff5e12e Каналы: добавить профиль через TEXT_CHANNEL_META 2026-08-01 01:57:41 +04:00
AidarKC 24afcba20a UI: скрыть действия у удалённых сообщений 2026-08-01 01:02:49 +04:00
AidarKC d5f41d12e8 UI: добавить просмотр данных блокчейна сообщения 2026-07-31 17:41:30 +04:00
AidarKC d640dd66e2 UI: улучшить превью и файлы вложений 2026-07-31 17:22:48 +04:00
AidarKC 05bd9f57d6 UI: закрепить панель журнала загрузок 2026-07-31 17:14:01 +04:00
AidarKC 60ab476ec7 UI: сделать плитки загрузок тоньше 2026-07-31 17:07:33 +04:00
AidarKC bcae285ab3 UI: поправить экран загрузки файлов 2026-07-31 16:59:04 +04:00
AidarKC cd67176b41 UI: уплотнить журнал загрузок Arweave 2026-07-31 13:50:17 +04:00
AidarKC 98eba54621 UI: добавить журнал загрузок Arweave 2026-07-31 13:13:58 +04:00
AidarKC c530627078 UI: добавить карусель вложений 2026-07-31 12:49:24 +04:00
AidarKC f961947e1e Документация и UI: уточнить формат вложений 2026-07-30 11:14:19 +04:00
AidarKC e9e6628b21 UI: добавить вложения Arweave в каналы 2026-07-30 11:04:50 +04:00
AidarKC c7684d6fe1 Merge migration-postgres into main 2026-07-30 09:48:13 +04:00
111 changed files with 11947 additions and 2456 deletions
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
}
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
yield new TextReplyBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
yield new TextRatingBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
@@ -64,11 +64,29 @@ public final class MsgSubType {
*/
public static final short TEXT_EDIT_REPLY = 21;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/**
* REPOST — репост сообщения в линии канала.
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 30;
public static final short TEXT_REPOST = 50;
/**
* CHANNEL_META — скрытый технический снимок профиля канала.
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
*/
public static final short TEXT_CHANNEL_META = 90;
/** ENTRYPOINT — входная страница канала (line-based). */
public static final short TEXT_ENTRYPOINT = 100;
/** EXERCISE — упражнение/комплекс (line-based). */
public static final short TEXT_EXERCISE = 110;
/** SERVICE — услуга/процедура (line-based). */
public static final short TEXT_SERVICE = 120;
/** COURSE — курс (line-based). */
public static final short TEXT_COURSE = 130;
/* ===================== REACTION (msg_type=2) ===================== */
@@ -138,4 +156,16 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
}
@@ -19,7 +19,7 @@ import java.util.Objects;
* [1] channelNameLen
* [N] channelName UTF-8
* [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes)
* [M] channelDescription UTF-8 (0..2048 bytes)
* [2] channelTypeCode (uint16)
* [2] channelTypeVersion (uint16)
*/
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static final byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 32;
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
public final short subType;
public final short version;
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
if (bb.remaining() != descriptionLen + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int typeCode = Short.toUnsignedInt(channelTypeCode);
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static String normalizeDescription(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
@Override
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
}
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
@Override
public int lineSeq() { return thisLineNumber; }
}
@@ -0,0 +1,166 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* StatusActionBody — type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 5;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
}
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
}
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null) throw new IllegalArgumentException("message is null");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
}
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -16,11 +16,16 @@ import java.util.Objects;
* subType:
* - POST (10)
* - EDIT_POST (11)
* - REPOST (30)
* - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
*
* Формат bodyBytes (BigEndian):
*
* POST:
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
ensureNoTail(bb, "TextLineBody");
}
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
if (requiresNonBlankMessage(st) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
} else {
if (message == null || message.isBlank())
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
}
return this;
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
private static boolean requiresNonBlankMessage(int st) {
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -0,0 +1,157 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody — type=1, ver=1.
*
* subType:
* - RATING (30)
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -20,9 +20,11 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_1 = 1;
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
public static final int SCHEMA_VERSION_4 = 4;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
private DatabaseInitializer() {}
@@ -30,7 +32,13 @@ public final class DatabaseInitializer {
public static final short TEXT_EDIT_POST = 11;
public static final short TEXT_REPLY = 20;
public static final short TEXT_EDIT_REPLY = 21;
public static final short TEXT_REPOST = 30;
public static final short TEXT_RATING = 30;
public static final short TEXT_REPOST = 50;
public static final short TEXT_CHANNEL_META = 90;
public static final short TEXT_ENTRYPOINT = 100;
public static final short TEXT_EXERCISE = 110;
public static final short TEXT_SERVICE = 120;
public static final short TEXT_COURSE = 130;
public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2;
@@ -88,6 +96,10 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_3) {
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
currentVersion = SCHEMA_VERSION_3;
}
if (currentVersion < SCHEMA_VERSION_4) {
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
}
}
}
@@ -30,8 +30,23 @@ public final class MsgSubType {
/** EDIT_REPLY — редактирование исходного ответа. */
public static final short TEXT_EDIT_REPLY = 21;
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
public static final short TEXT_REPOST = 30;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
public static final short TEXT_REPOST = 50;
/** CHANNEL_META — скрытый технический снимок профиля канала. */
public static final short TEXT_CHANNEL_META = 90;
/** ENTRYPOINT — входная страница канала (line-based). */
public static final short TEXT_ENTRYPOINT = 100;
/** EXERCISE — упражнение/комплекс (line-based). */
public static final short TEXT_EXERCISE = 110;
/** SERVICE — услуга/процедура (line-based). */
public static final short TEXT_SERVICE = 120;
/** COURSE — курс (line-based). */
public static final short TEXT_COURSE = 130;
/* ===================== REACTION (msg_type=2) ===================== */
@@ -120,6 +135,18 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
// не трогая уже занятые коды.
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
channel_type_version,
channel_root_block_number,
channel_root_block_hash,
created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at_ms,
ava_ar,
ava_sha256,
ava_size,
meta_updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug());
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
ps.setInt(8, entry.getChannelRootBlockNumber());
ps.setBytes(9, entry.getChannelRootBlockHash());
ps.setLong(10, entry.getCreatedAtMs());
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
ps.executeUpdate();
}
}
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
String sql = """
UPDATE channel_names_state
SET display_name = ?,
channel_description = ?,
ava_ar = ?,
ava_sha256 = ?,
ava_size = ?,
meta_updated_at_ms = ?
WHERE owner_bch_name = ? AND channel_root_block_number = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
ps.setString(7, entry.getOwnerBlockchainName());
ps.setInt(8, entry.getChannelRootBlockNumber());
ps.executeUpdate();
}
}
@@ -13,7 +13,7 @@ import java.util.List;
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
* - login цели (channelLogin)
* - blockchainName цели (channelBchName)
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - last publication: bytes оригинального блока (для timestamp)
* - last publication: bytes актуального блока (edit или orig) — для текста превью
*
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
/**
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - последнюю публикацию (orig bytes) + её edit (если есть)
*
* Поведение при 0 публикаций:
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub AS (
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub_block AS (
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
// last_pub
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
private int channelRootBlockNumber;
private byte[] channelRootBlockHash;
private long createdAtMs;
private String avaAr;
private String avaSha256;
private long avaSize;
private long metaUpdatedAtMs;
public String getSlug() {
return slug;
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
public void setCreatedAtMs(long createdAtMs) {
this.createdAtMs = createdAtMs;
}
public String getAvaAr() {
return avaAr;
}
public void setAvaAr(String avaAr) {
this.avaAr = avaAr;
}
public String getAvaSha256() {
return avaSha256;
}
public void setAvaSha256(String avaSha256) {
this.avaSha256 = avaSha256;
}
public long getAvaSize() {
return avaSize;
}
public void setAvaSize(long avaSize) {
this.avaSize = avaSize;
}
public long getMetaUpdatedAtMs() {
return metaUpdatedAtMs;
}
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
this.metaUpdatedAtMs = metaUpdatedAtMs;
}
}
@@ -53,12 +53,16 @@ BEGIN
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -92,12 +96,16 @@ BEGIN
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE channel_names_state
ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 4, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -162,12 +162,16 @@ BEGIN
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -201,12 +205,16 @@ BEGIN
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
@@ -536,7 +544,11 @@ CREATE TABLE IF NOT EXISTS channel_names_state (
channel_type_version INTEGER NOT NULL DEFAULT 1,
channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BYTEA NOT NULL,
created_at_ms BIGINT NOT NULL
created_at_ms BIGINT NOT NULL,
ava_ar TEXT NOT NULL DEFAULT '',
ava_sha256 TEXT NOT NULL DEFAULT '',
ava_size BIGINT NOT NULL DEFAULT 0,
meta_updated_at_ms BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
@@ -67,6 +67,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
@@ -75,6 +76,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
@@ -184,6 +186,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
@@ -265,6 +268,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
private SolanaUserPdaImportService() {}
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
String serverAddress = safe(serverProfile.serverAddress());
if (serverAddress.isBlank()) continue;
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
}
return new ArrayList<>(routes.values());
}
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
int n = u8(raw, c++);
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
accessServers.add(normalizeLogin(accessServerLogin));
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
continue;
}
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalizedAccessServerLogin);
}
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget;
import blockchain.body.CreateChannelBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody;
import org.slf4j.Logger;
@@ -21,6 +22,7 @@ import server.sync.BlockchainResyncGuard;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.WireCodes;
import server.sync.AddBlockSyncService;
@@ -166,6 +168,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_name_already_exists" -> "Такое название канала уже занято";
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST";
case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION";
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
default -> "Ошибка: " + code;
@@ -277,6 +282,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
ChannelNameStateEntry channelNameStateEntry = null;
ChannelNameStateEntry channelMetaUpdateEntry = null;
Chat200CreateSeed chat200CreateSeed = null;
if (block.body instanceof CreateChannelBody createChannelBody) {
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
@@ -302,14 +308,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
String displayName = normalizedName;
String channelDescription = createChannelBody.channelDescription == null
? ""
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
try {
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, normalizedName);
displayName = createMeta.title();
channelDescription = createMeta.description();
avaAr = createMeta.avaAr();
avaSha256 = createMeta.avaSha256();
avaSize = createMeta.avaSize();
metaUpdatedAtMs = block.timestamp * 1000L;
} catch (IllegalArgumentException e) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
}
}
channelNameStateEntry = new ChannelNameStateEntry();
channelNameStateEntry.setSlug(slug);
channelNameStateEntry.setDisplayName(normalizedName);
channelNameStateEntry.setChannelDescription(
createChannelBody.channelDescription == null
? ""
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
);
channelNameStateEntry.setDisplayName(displayName);
channelNameStateEntry.setChannelDescription(channelDescription);
channelNameStateEntry.setAvaAr(avaAr);
channelNameStateEntry.setAvaSha256(avaSha256);
channelNameStateEntry.setAvaSize(avaSize);
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
channelNameStateEntry.setOwnerLogin(login);
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
@@ -329,6 +357,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
}
} else if ((block.type & 0xFFFF) == 1
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& block.body instanceof TextLineBody metaBody) {
int lineCode = metaBody.lineCode();
if (lineCode <= 0) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_meta_line", serverLastNum, serverLastHashHex);
}
ExistingChannelState existingChannel;
try {
existingChannel = loadExistingChannelState(blockchainName, lineCode);
} catch (Exception e) {
log.error("AddBlock: channel_meta_lookup_failed (login={}, blockchainName={}, lineCode={})",
login, blockchainName, lineCode, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
if (existingChannel == null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_not_found", serverLastNum, serverLastHashHex);
}
ChannelMetaTextParser.Parsed parsedMeta;
try {
parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, existingChannel.slug);
} catch (IllegalArgumentException e) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
}
channelMetaUpdateEntry = new ChannelNameStateEntry();
channelMetaUpdateEntry.setOwnerBlockchainName(blockchainName);
channelMetaUpdateEntry.setChannelRootBlockNumber(lineCode);
channelMetaUpdateEntry.setDisplayName(parsedMeta.title());
channelMetaUpdateEntry.setChannelDescription(parsedMeta.description());
channelMetaUpdateEntry.setAvaAr(parsedMeta.avaAr());
channelMetaUpdateEntry.setAvaSha256(parsedMeta.avaSha256());
channelMetaUpdateEntry.setAvaSize(parsedMeta.avaSize());
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
}
if ((block.type & 0xFFFF) == 1
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
try {
String editError = validateEditPostTarget(blockchainName, block);
if (editError != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.error("AddBlock: edit_post_target_check_failed (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
}
if ((block.type & 0xFFFF) == 5) {
try {
String statusError = validateStatusActionTarget(block);
if (statusError != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.error("AddBlock: status_action_target_check_failed (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
}
// 4.2) запрет дырок: blockNumber строго last+1
@@ -485,7 +573,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
);
}
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed);
@@ -544,6 +632,86 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
long updatedAtMs;
}
private static final class ExistingChannelState {
String slug;
}
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
if (!(block.body instanceof TextLineBody editBody)) return null;
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
byte[] targetHash = editBody.toBlockHashBytes();
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
}
if (target.getMsgType() != 1) return null;
if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) {
return "entrypoint_edit_forbidden";
}
return null;
}
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
String targetBch = statusBody.toBchName();
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
byte[] targetHash = statusBody.toBlockHashBytes();
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return "bad_block_body";
}
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
}
int statusSubType = block.subType & 0xFFFF;
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
if (target.getMsgType() != 5) {
return "status_confirmed_target_must_be_status_action";
}
return null;
}
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
int targetSubType = target.getMsgSubType();
if (statusSubType == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_LEARNED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
if (statusSubType == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
if (statusSubType == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_COMPLETED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_COURSE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
return "status_action_target_not_allowed";
}
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT slug
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
ExistingChannelState state = new ExistingChannelState();
state.slug = rs.getString("slug");
return state;
}
}
}
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
BlockchainStateEntry st,
BlockEntry be,
UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
ChannelNameStateEntry channelNameStateEntry,
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes();
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
channelNameStateDAO.insert(c, channelNameStateEntry);
}
if (channelMetaUpdateEntry != null) {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
c.commit();
committed = true;
} catch (Exception e) {
@@ -0,0 +1,112 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import java.util.HashMap;
import java.util.Map;
public final class ChannelMetaTextParser {
public static final int MAX_TITLE_CHARS = 50;
public static final int MAX_DESCRIPTION_CHARS = 250;
private ChannelMetaTextParser() {}
public static Parsed parseStrict(String rawText, String fallbackTitle) {
String text = String.valueOf(rawText == null ? "" : rawText);
String title = "";
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
boolean seenTitle = false;
boolean seenAvatar = false;
int offset = 0;
while (text.startsWith("<SHiNE:", offset)) {
int end = text.indexOf('>', offset);
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
String body = text.substring(offset + "<SHiNE:".length(), end);
if (body.startsWith("title;")) {
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
seenTitle = true;
title = parseTitleTag(body);
} else if (body.startsWith("avatar;")) {
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
seenAvatar = true;
Avatar avatar = parseAvatarTag(body);
avaAr = avatar.ar;
avaSha256 = avatar.sha256;
avaSize = avatar.size;
} else {
throw new IllegalArgumentException("unknown_channel_meta_tag");
}
offset = end + 1;
if (offset < text.length() && text.charAt(offset) == '\n') offset += 1;
}
String description = text.substring(offset).trim();
if (countCodePoints(description) > MAX_DESCRIPTION_CHARS) {
throw new IllegalArgumentException("channel_meta_description_too_long");
}
String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim();
validateTitle(cleanTitle);
return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize);
}
private static String parseTitleTag(String body) {
String prefix = "title;v=1;";
if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title");
String title = body.substring(prefix.length()).trim();
validateTitle(title);
return title;
}
private static Avatar parseAvatarTag(String body) {
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
long size;
try {
size = Long.parseLong(sizeRaw);
} catch (Exception e) {
throw new IllegalArgumentException("bad_channel_meta_avatar_size");
}
if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size");
return new Avatar(ar, sha256, size);
}
private static Map<String, String> parseFields(String raw) {
Map<String, String> out = new HashMap<>();
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
int eq = part.indexOf('=');
if (eq <= 0) continue;
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
if (!key.isEmpty()) out.put(key, value);
}
return out;
}
private static void validateTitle(String title) {
String value = String.valueOf(title == null ? "" : title);
if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0
|| value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
|| value.indexOf('\0') >= 0) {
throw new IllegalArgumentException("bad_channel_meta_title_chars");
}
if (countCodePoints(value) > MAX_TITLE_CHARS) {
throw new IllegalArgumentException("channel_meta_title_too_long");
}
}
private static int countCodePoints(String value) {
return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length());
}
private record Avatar(String ar, String sha256, long size) {}
public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {}
}
@@ -1,7 +1,9 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.MsgSubType;
import blockchain.body.CreateChannelBody;
import blockchain.body.TextLineBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.DbController;
@@ -20,6 +22,7 @@ import java.util.Map;
public final class ChannelNamesStateBootstrapper {
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
private static final int MSG_TYPE_TECH = 0;
private static final int MSG_TYPE_TEXT = 1;
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
private static volatile boolean bootstrapped;
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
continue;
}
final String displayName;
String displayName;
final String slug;
final String channelDescription;
String channelDescription;
final int channelTypeCode;
final int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
try {
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
slug = ChannelNameRules.toCanonicalSlug(displayName);
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, displayName);
displayName = createMeta.title();
channelDescription = createMeta.description();
avaAr = createMeta.avaAr();
avaSha256 = createMeta.avaSha256();
avaSize = createMeta.avaSize();
metaUpdatedAtMs = parsed.timestamp * 1000L;
}
} catch (Exception badName) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
continue;
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
entry.setSlug(slug);
entry.setDisplayName(displayName);
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
entry.setAvaAr(avaAr);
entry.setAvaSha256(avaSha256);
entry.setAvaSize(avaSize);
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
entry.setOwnerLogin(ownerLogin);
entry.setOwnerBlockchainName(ownerBch);
entry.setChannelTypeCode(channelTypeCode);
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
dao.clearAll(c);
dao.insertAll(c, entries);
applyMetaUpdates(c, dao, entries, skipped);
c.commit();
log.info("channel_names_state bootstrapped: {}", entries.size());
if (!conflicts.isEmpty()) {
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
}
}
private static void applyMetaUpdates(Connection c,
ChannelNameStateDAO dao,
List<ChannelNameStateEntry> entries,
List<String> skipped) throws Exception {
Map<String, String> fallbackTitles = new LinkedHashMap<>();
for (ChannelNameStateEntry entry : entries) {
fallbackTitles.put(entry.getOwnerBlockchainName() + "#" + entry.getChannelRootBlockNumber(), entry.getSlug());
}
String sql = """
SELECT bch_name, block_number, block_bytes
FROM blocks
WHERE msg_type = ? AND msg_sub_type = ?
ORDER BY bch_name, block_number
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MSG_TYPE_TEXT);
ps.setInt(2, MsgSubType.TEXT_CHANNEL_META);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String ownerBch = rs.getString("bch_name");
int blockNumber = rs.getInt("block_number");
byte[] blockBytes = rs.getBytes("block_bytes");
try {
BchBlockEntry parsed = new BchBlockEntry(blockBytes);
if (!(parsed.body instanceof TextLineBody metaBody)) continue;
int lineCode = metaBody.lineCode();
String fallbackTitle = fallbackTitles.get(ownerBch + "#" + lineCode);
if (fallbackTitle == null) continue;
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, fallbackTitle);
ChannelNameStateEntry update = new ChannelNameStateEntry();
update.setOwnerBlockchainName(ownerBch);
update.setChannelRootBlockNumber(lineCode);
update.setDisplayName(parsedMeta.title());
update.setChannelDescription(parsedMeta.description());
update.setAvaAr(parsedMeta.avaAr());
update.setAvaSha256(parsedMeta.avaSha256());
update.setAvaSize(parsedMeta.avaSize());
update.setMetaUpdatedAtMs(parsed.timestamp * 1000L);
dao.updateMeta(c, update);
} catch (Exception e) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_meta)");
}
}
}
}
}
}
@@ -22,9 +22,8 @@ final class ChannelsReadSupport {
static final int MSG_TYPE_TEXT = 1;
static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0;
static final int MSG_TYPE_STATUS_ACTION = 5;
static final String STORIES_CHANNEL_NAME = "stories";
static final String COMMAND_PREFIX = "/.";
static final String COMMAND_DESC = "desc";
static final String COMMAND_ADD = "add";
static final String COMMAND_REMOVE = "remove";
@@ -47,11 +46,40 @@ final class ChannelsReadSupport {
if (rootNumber == 0) {
meta.channelName = STORIES_CHANNEL_NAME;
meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0);
meta.displayName = STORIES_CHANNEL_NAME;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
return meta;
}
String stateSql = """
SELECT slug, display_name, channel_description, channel_type_code, channel_type_version,
ava_ar, ava_sha256, ava_size, meta_updated_at_ms
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
meta.channelName = rs.getString("slug");
meta.displayName = rs.getString("display_name");
meta.channelDescription = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
meta.channelTypeCode = rs.getInt("channel_type_code");
meta.channelTypeVersion = rs.getInt("channel_type_version");
meta.avaAr = String.valueOf(rs.getString("ava_ar") == null ? "" : rs.getString("ava_ar"));
meta.avaSha256 = String.valueOf(rs.getString("ava_sha256") == null ? "" : rs.getString("ava_sha256"));
meta.avaSize = rs.getLong("ava_size");
meta.metaUpdatedAtMs = rs.getLong("meta_updated_at_ms");
return meta;
}
}
} catch (SQLException ignored) {
// fallback ниже для окружений до миграции.
}
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
@@ -68,21 +96,29 @@ final class ChannelsReadSupport {
BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) {
meta.channelName = ccb.channelName;
meta.displayName = ccb.channelName;
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(meta.channelDescription, meta.channelName);
meta.displayName = parsedMeta.title();
meta.channelDescription = parsedMeta.description();
meta.avaAr = parsedMeta.avaAr();
meta.avaSha256 = parsedMeta.avaSha256();
meta.avaSize = parsedMeta.avaSize();
meta.metaUpdatedAtMs = e.timestamp * 1000L;
}
} else {
meta.channelName = null;
meta.displayName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
}
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (updatedDescription != null) {
meta.channelDescription = updatedDescription;
}
return meta;
} catch (Exception ignored) {
meta.channelName = null;
meta.displayName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
return meta;
@@ -91,13 +127,17 @@ final class ChannelsReadSupport {
}
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?";
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
@@ -106,9 +146,9 @@ final class ChannelsReadSupport {
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
ORDER BY block_number DESC
LIMIT 1
""";
@@ -117,7 +157,11 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
@@ -133,7 +177,7 @@ final class ChannelsReadSupport {
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
@@ -169,6 +213,10 @@ final class ChannelsReadSupport {
ti.text = tlb.message;
} else if (e.body instanceof TextReplyBody trb) {
ti.text = trb.message;
} else if (e.body instanceof blockchain.body.TextRatingBody trb) {
ti.text = trb.message;
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
ti.text = sab.message;
} else if (e.body instanceof TextBody tb) {
ti.text = tb.message;
}
@@ -183,7 +231,7 @@ final class ChannelsReadSupport {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
ORDER BY block_number
""" + order + " LIMIT ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -191,8 +239,12 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(6, limit);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
ps.setInt(10, limit);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
@@ -257,21 +309,47 @@ final class ChannelsReadSupport {
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
int likesCount = 0;
int repliesCount = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new int[] {0, 0};
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
if (rs.next()) {
likesCount = rs.getInt("likes_count");
repliesCount = rs.getInt("replies_count");
}
}
}
String ratingsSql = """
SELECT COUNT(*)
FROM blocks
WHERE msg_type = ?
AND msg_sub_type = ?
AND to_bch_name = ?
AND to_block_number = ?
AND to_block_hash = ?
""";
int ratingsCount = 0;
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
ps.setInt(1, MSG_TYPE_TEXT);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, bch);
ps.setInt(4, blockNumber);
ps.setBytes(5, blockHash);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
ratingsCount = rs.getInt(1);
}
}
}
return new int[] {likesCount, repliesCount, ratingsCount};
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
return fromCommand == null ? "" : fromCommand;
return "";
}
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
@@ -287,8 +365,7 @@ final class ChannelsReadSupport {
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
return fromCommand == null ? saved : fromCommand;
return saved;
}
}
} catch (SQLException ignored) {
@@ -306,8 +383,6 @@ final class ChannelsReadSupport {
BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (fromCommand != null) return fromCommand;
return ccb.channelDescription == null ? "" : ccb.channelDescription;
}
return "";
@@ -363,45 +438,97 @@ final class ChannelsReadSupport {
return meta.channelTypeVersion;
}
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT block_bytes
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
List<MetaEvent> out = new ArrayList<>();
if (lineCode <= 0) return out;
ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode);
String rootSql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
ORDER BY block_number DESC
LIMIT 300
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TECH);
ps.setInt(3, 1);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
MetaEvent event = new MetaEvent();
event.kind = "created";
event.login = rs.getString("login");
event.bchName = rs.getString("bch_name");
event.blockNumber = rs.getInt("block_number");
event.blockHash = rs.getBytes("block_hash");
event.lineStep = null;
try {
BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes"));
event.createdAtMs = entry.timestamp * 1000L;
if (entry.body instanceof CreateChannelBody ccb) {
event.title = ccb.channelName;
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
if (Short.toUnsignedInt(ccb.channelTypeCode) == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(event.description, ccb.channelName);
event.title = parsedMeta.title();
event.description = parsedMeta.description();
event.avaAr = parsedMeta.avaAr();
event.avaSha256 = parsedMeta.avaSha256();
event.avaSize = parsedMeta.avaSize();
}
}
} catch (Exception ignored) {
event.createdAtMs = 0L;
}
if (event.title == null || event.title.isBlank()) {
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
}
if (event.description == null) event.description = "";
if (event.avaAr == null) event.avaAr = "";
if (event.avaSha256 == null) event.avaSha256 = "";
out.add(event);
}
}
}
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
CommandInfo commandInfo = parseCommandText(info.text);
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
return commandInfo.arg;
byte[] bytes = rs.getBytes("block_bytes");
TextInfo info = parseTextAndTime(bytes);
try {
ChannelMetaTextParser.Parsed parsed = ChannelMetaTextParser.parseStrict(info.text, base.channelName);
MetaEvent event = new MetaEvent();
event.kind = "profile_updated";
event.login = rs.getString("login");
event.bchName = rs.getString("bch_name");
event.blockNumber = rs.getInt("block_number");
event.blockHash = rs.getBytes("block_hash");
event.lineStep = (Integer) rs.getObject("this_line_number");
event.createdAtMs = info.createdAtMs;
event.title = parsed.title();
event.description = parsed.description();
event.avaAr = parsed.avaAr();
event.avaSha256 = parsed.avaSha256();
event.avaSize = parsed.avaSize();
out.add(event);
} catch (Exception ignored) {
// Невалидный meta-блок не участвует в системной истории UI.
}
}
}
}
return null;
}
static CommandInfo parseCommandText(String text) {
String value = String.valueOf(text == null ? "" : text).trim();
if (!value.startsWith(COMMAND_PREFIX)) return null;
String raw = value.substring(COMMAND_PREFIX.length()).trim();
if (raw.isEmpty()) return null;
int sp = raw.indexOf(' ');
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
String arg = sp < 0 ? "" : raw.substring(sp + 1).trim();
if (cmd.isEmpty()) return null;
return new CommandInfo(cmd, arg);
return out;
}
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
@@ -506,6 +633,22 @@ final class ChannelsReadSupport {
return sb.toString();
}
static boolean isChannelFeedSubType(int subType) {
return subType == MsgSubType.TEXT_POST
|| subType == MsgSubType.TEXT_REPOST
|| subType == MsgSubType.TEXT_ENTRYPOINT
|| subType == MsgSubType.TEXT_EXERCISE
|| subType == MsgSubType.TEXT_SERVICE
|| subType == MsgSubType.TEXT_COURSE;
}
static boolean supportsEditPostVersions(int subType) {
return subType == MsgSubType.TEXT_POST
|| subType == MsgSubType.TEXT_EXERCISE
|| subType == MsgSubType.TEXT_SERVICE
|| subType == MsgSubType.TEXT_COURSE;
}
static final class PostBlock {
String login;
String bchName;
@@ -526,19 +669,29 @@ final class ChannelsReadSupport {
static final class ChannelMeta {
String channelName;
String displayName;
String channelDescription;
int channelTypeCode;
int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
}
static final class CommandInfo {
final String command;
final String arg;
CommandInfo(String command, String arg) {
this.command = command;
this.arg = arg;
}
static final class MetaEvent {
String kind;
String login;
String bchName;
int blockNumber;
byte[] blockHash;
Integer lineStep;
long createdAtMs;
String title;
String description;
String avaAr;
String avaSha256;
long avaSize;
}
static final class PairChannelSelector {
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
channel.setChannelName(meta.channelName);
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
channel.setChannelDescription(meta.channelDescription);
channel.setAvaAr(meta.avaAr);
channel.setAvaSha256(meta.avaSha256);
channel.setAvaSize(meta.avaSize);
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
channel.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setChannelRoot(rootRef);
resp.setChannel(channel);
List<Net_GetChannelMessages_Response.ChannelMetaEvent> metaEvents = new ArrayList<>();
for (ChannelsReadSupport.MetaEvent event : ChannelsReadSupport.channelMetaEvents(c, ownerBch, lineCode)) {
Net_GetChannelMessages_Response.ChannelMetaEvent outEvent = new Net_GetChannelMessages_Response.ChannelMetaEvent();
outEvent.setKind(event.kind);
Net_GetChannelMessages_Response.BlockRef eventRef = new Net_GetChannelMessages_Response.BlockRef();
eventRef.setBlockNumber(event.blockNumber);
eventRef.setBlockHash(ChannelsReadSupport.toHex(event.blockHash));
outEvent.setMessageRef(eventRef);
outEvent.setAuthorLogin(event.login);
outEvent.setAuthorBlockchainName(event.bchName);
outEvent.setLineStep(event.lineStep);
outEvent.setCreatedAtMs(event.createdAtMs);
outEvent.setTitle(event.title);
outEvent.setDescription(event.description);
outEvent.setAvaAr(event.avaAr);
outEvent.setAvaSha256(event.avaSha256);
outEvent.setAvaSize(event.avaSize);
metaEvents.add(outEvent);
}
resp.setMetaEvents(metaEvents);
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
);
@@ -117,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
v1.setCreatedAtMs(postText.createdAtMs);
versionsOut.add(v1);
if (post.msgSubType == MsgSubType.TEXT_POST) {
if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) {
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
@@ -141,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
item.setRatingsCount(stats[2]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item);
@@ -17,6 +17,7 @@ import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
@@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
if (depthDown <= 0) return List.of();
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<PostRow> replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
for (PostRow row : replies) {
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
return out;
}
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE msg_type=1 AND msg_sub_type=?
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
LIMIT ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MsgSubType.TEXT_REPLY);
ps.setString(2, toBchName);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
ps.setInt(5, limit);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, toBchName);
ps.setInt(4, toBlockNumber);
ps.setBytes(5, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));
out.sort(Comparator
.comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs)
.thenComparing(row -> String.valueOf(row.bchName))
.thenComparingInt(row -> row.blockNumber));
if (out.size() > limit) {
return new ArrayList<>(out.subList(0, limit));
}
return out;
}
}
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
first.setCreatedAtMs(base.createdAtMs);
versions.add(first);
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) {
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
if (row.msgSubType == MsgSubType.TEXT_REPLY
|| row.msgSubType == MsgSubType.TEXT_RATING
|| ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
short editType = (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_RATING)
? MsgSubType.TEXT_EDIT_REPLY
: MsgSubType.TEXT_EDIT_POST;
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
@@ -233,6 +243,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
node.setRatingsCount(stats[2]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
@@ -0,0 +1,238 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.body.StatusActionBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.handlers.channels.entyties.Net_GetChannelMessages_Response;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetPersonalDiary_Handler.class);
private static final String DIARY_CHANNEL_NAME = "diary";
private static final String DIARY_DISPLAY_NAME = "Личный дневник";
private static final String DIARY_DESCRIPTION = "История ваших действий по упражнениям, услугам и курсам";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetPersonalDiary_Request req = (Net_GetPersonalDiary_Request) baseRequest;
String requestedLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim();
if (requestedLogin.isBlank() && (ctx == null || ctx.getLogin() == null || ctx.getLogin().isBlank())) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
}
int limit = req.getLimit() == null ? 200 : req.getLimit();
if (limit <= 0 || limit > 1000) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
}
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
try (Connection c = DbController.getInstance().getConnection()) {
String viewerLogin = ctx != null ? String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim() : "";
String canonicalLogin = !viewerLogin.isBlank()
? viewerLogin
: ChannelsReadSupport.canonicalLogin(c, requestedLogin);
if (canonicalLogin == null || canonicalLogin.isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
}
if (!viewerLogin.isBlank() && !viewerLogin.equalsIgnoreCase(canonicalLogin)) {
return NetExceptionResponseFactory.error(req, 403, "forbidden", "Личный дневник доступен только владельцу");
}
String ownerBch = loadPrimaryBlockchainName(c, canonicalLogin);
if (ownerBch == null || ownerBch.isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "blockchain_not_found", "Не найден blockchain пользователя");
}
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
channel.setOwnerLogin(canonicalLogin);
channel.setOwnerBlockchainName(ownerBch);
channel.setChannelName(DIARY_CHANNEL_NAME);
channel.setDisplayName(DIARY_DISPLAY_NAME);
channel.setChannelDescription(DIARY_DESCRIPTION);
channel.setChannelTypeCode(900);
channel.setChannelTypeVersion(1);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(0);
rootRef.setBlockHash(ChannelsReadSupport.toHex(new byte[32]));
channel.setChannelRoot(rootRef);
resp.setChannel(channel);
resp.setMetaEvents(new ArrayList<>());
List<Net_GetChannelMessages_Response.MessageItem> items = loadDiaryItems(c, canonicalLogin, limit, asc);
resp.setMessages(items);
return resp;
} catch (Exception e) {
log.error("GetPersonalDiary failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private String loadPrimaryBlockchainName(Connection c, String canonicalLogin) throws Exception {
try (PreparedStatement ps = c.prepareStatement("""
SELECT blockchain_name
FROM blockchain_state
WHERE login = ?
ORDER BY blockchain_name
LIMIT 1
""")) {
ps.setString(1, canonicalLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("blockchain_name") : null;
}
}
}
private List<Net_GetChannelMessages_Response.MessageItem> loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception {
String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
FROM blocks
WHERE login = ? AND msg_type = ?
ORDER BY block_number
""" + order + " LIMIT ?")) {
ps.setString(1, canonicalLogin);
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_STATUS_ACTION);
ps.setInt(3, limit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
byte[] blockBytes = rs.getBytes("block_bytes");
BchBlockEntry entry = new BchBlockEntry(blockBytes);
if (!(entry.body instanceof StatusActionBody statusBody)) continue;
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
ref.setBlockNumber(rs.getInt("block_number"));
ref.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
item.setMessageRef(ref);
item.setMsgSubType(rs.getInt("msg_sub_type"));
item.setAuthorLogin(rs.getString("login"));
item.setAuthorBlockchainName(rs.getString("bch_name"));
item.setCreatedAtMs(entry.timestamp * 1000L);
item.setLikesCount(0);
item.setLikedByMe(false);
item.setRepliesCount(0);
item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName());
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c,
rs.getString("bch_name"),
rs.getInt("block_number"),
rs.getBytes("block_hash"),
statusBody.message == null ? "" : statusBody.message,
entry.timestamp * 1000L
);
item.setVersions(versions);
item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
out.add(item);
}
}
}
return out;
}
private List<Net_GetChannelMessages_Response.VersionItem> loadVersionsForDiaryItem(Connection c,
String ownerBch,
int originalBlockNumber,
byte[] originalBlockHash,
String originalText,
long originalCreatedAtMs) throws Exception {
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
first.setVersionIndex(1);
first.setBlockNumber(originalBlockNumber);
first.setBlockHash(ChannelsReadSupport.toHex(originalBlockHash));
first.setText(originalText == null ? "" : originalText);
first.setCreatedAtMs(originalCreatedAtMs);
versions.add(first);
try (PreparedStatement ps = c.prepareStatement("""
SELECT block_number, block_hash, block_bytes
FROM blocks
WHERE bch_name = ?
AND msg_type = ?
AND msg_sub_type = ?
AND to_block_number = ?
AND to_block_hash = ?
AND (to_bch_name = ? OR to_bch_name IS NULL OR to_bch_name = '')
ORDER BY block_number ASC
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_TEXT);
ps.setInt(3, shine.db.MsgSubType.TEXT_EDIT_REPLY);
ps.setInt(4, originalBlockNumber);
ps.setBytes(5, originalBlockHash);
ps.setString(6, ownerBch);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
Net_GetChannelMessages_Response.VersionItem item = new Net_GetChannelMessages_Response.VersionItem();
item.setVersionIndex(versions.size() + 1);
item.setBlockNumber(rs.getInt("block_number"));
item.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
item.setText(editText.text);
item.setCreatedAtMs(editText.createdAtMs);
versions.add(item);
}
}
}
return versions;
}
private void fillTargetDetails(Connection c,
Net_GetChannelMessages_Response.MessageItem item,
String targetBch,
Integer targetBlockNumber,
byte[] targetHash) throws Exception {
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return;
}
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
""")) {
ps.setString(1, targetBch);
ps.setInt(2, targetBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return;
byte[] actualHash = rs.getBytes("block_hash");
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
item.setTargetAuthorLogin(rs.getString("login"));
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
item.setTargetText(textInfo.text);
item.setTargetCreatedAtMs(textInfo.createdAtMs);
}
}
}
}
@@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(meta.channelName);
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
channelRef.setChannelDescription(meta.channelDescription);
channelRef.setAvaAr(meta.avaAr);
channelRef.setAvaSha256(meta.avaSha256);
channelRef.setAvaSize(meta.avaSize);
channelRef.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
channelRef.setChannelTypeCode(meta.channelTypeCode);
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
channelRef.setPersonal(meta.channelTypeCode == 100);
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
AND block_number = ?
AND block_hash = ?
AND msg_type = ?
AND msg_sub_type = ?
AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
%s
LIMIT 1
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
existsPs.setInt(5, MsgSubType.TEXT_POST);
existsPs.setInt(6, MsgSubType.TEXT_REPOST);
existsPs.setInt(7, MsgSubType.TEXT_ENTRYPOINT);
existsPs.setInt(8, MsgSubType.TEXT_EXERCISE);
existsPs.setInt(9, MsgSubType.TEXT_SERVICE);
existsPs.setInt(10, MsgSubType.TEXT_COURSE);
if (strictChannelMatch) {
existsPs.setInt(6, expectedRoot);
existsPs.setInt(11, expectedRoot);
}
boolean exists;
@@ -8,6 +8,7 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel;
private List<MessageItem> messages = new ArrayList<>();
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; }
@@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public List<ChannelMetaEvent> getMetaEvents() { return metaEvents; }
public void setMetaEvents(List<ChannelMetaEvent> metaEvents) { this.metaEvents = metaEvents; }
public static class Channel {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private String displayName;
private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private BlockRef channelRoot;
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
public String getAvaAr() { return avaAr; }
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
public String getAvaSha256() { return avaSha256; }
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
public Long getAvaSize() { return avaSize; }
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
public Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
@@ -47,6 +71,53 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
public static class ChannelMetaEvent {
private String kind;
private BlockRef messageRef;
private String authorLogin;
private String authorBlockchainName;
private Integer lineStep;
private long createdAtMs;
private String title;
private String description;
private String avaAr;
private String avaSha256;
private Long avaSize;
public String getKind() { return kind; }
public void setKind(String kind) { this.kind = kind; }
public BlockRef getMessageRef() { return messageRef; }
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
public String getAuthorLogin() { return authorLogin; }
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
public String getAuthorBlockchainName() { return authorBlockchainName; }
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
public Integer getLineStep() { return lineStep; }
public void setLineStep(Integer lineStep) { this.lineStep = lineStep; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getAvaAr() { return avaAr; }
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
public String getAvaSha256() { return avaSha256; }
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
public Long getAvaSize() { return avaSize; }
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
}
public static class MessageItem {
private BlockRef messageRef;
private Integer msgSubType;
@@ -56,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String targetBlockchainName;
private Integer targetBlockNumber;
private String targetBlockHash;
private Integer targetMsgSubType;
private String targetText;
private String targetAuthorLogin;
private String targetAuthorBlockchainName;
private Long targetCreatedAtMs;
private long createdAtMs;
private String text;
private int likesCount;
private boolean likedByMe;
private int repliesCount;
private int ratingsCount;
private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>();
@@ -87,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
public Integer getTargetMsgSubType() { return targetMsgSubType; }
public void setTargetMsgSubType(Integer targetMsgSubType) { this.targetMsgSubType = targetMsgSubType; }
public String getTargetText() { return targetText; }
public void setTargetText(String targetText) { this.targetText = targetText; }
public String getTargetAuthorLogin() { return targetAuthorLogin; }
public void setTargetAuthorLogin(String targetAuthorLogin) { this.targetAuthorLogin = targetAuthorLogin; }
public String getTargetAuthorBlockchainName() { return targetAuthorBlockchainName; }
public void setTargetAuthorBlockchainName(String targetAuthorBlockchainName) { this.targetAuthorBlockchainName = targetAuthorBlockchainName; }
public Long getTargetCreatedAtMs() { return targetCreatedAtMs; }
public void setTargetCreatedAtMs(Long targetCreatedAtMs) { this.targetCreatedAtMs = targetCreatedAtMs; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
@@ -102,6 +194,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
public int getRatingsCount() { return ratingsCount; }
public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; }
public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
@@ -0,0 +1,18 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetPersonalDiary_Request extends Net_Request {
private String login;
private Integer limit;
private String sort;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public String getSort() { return sort; }
public void setSort(String sort) { this.sort = sort; }
}
@@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private String displayName;
private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private boolean personal;
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
public String getAvaAr() { return avaAr; }
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
public String getAvaSha256() { return avaSha256; }
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
public Long getAvaSize() { return avaSize; }
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
public Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
@@ -26,6 +26,7 @@ import java.util.Set;
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_INVITE = 100;
private static final int TYPE_CONNECT_START = 170;
private static final long PUSH_CALL_TTL_MS = 10_000L;
@Override
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
int type = req.getType() == null ? TYPE_INVITE : req.getType();
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
String data = req.getData() == null ? "" : req.getData().trim();
boolean isInvite = type == TYPE_INVITE;
boolean isConnectStart = type == TYPE_CONNECT_START;
if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны");
}
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
payload.put("fromSessionId", ctx.getSessionId());
payload.put("toLogin", to);
payload.put("callId", callId);
payload.put("type", TYPE_INVITE);
payload.put("type", type);
payload.put("timeMs", timeMs);
if (!data.isBlank()) {
payload.put("data", data);
}
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
if (sent) wsDelivered++;
}
if (isConnectStart) {
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setCallId(callId);
resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(0);
resp.setDeliveredWebPushSessions(0);
return resp;
}
for (ActiveSessionEntry session : allTargetSessions) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
@@ -12,10 +12,12 @@ 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_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.CurrentUserEntry;
@@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
private static final String TARGET_MODE_SINGLE = "single_session";
private static final String TARGET_MODE_ALL = "all_sessions";
private static final long ALLOWED_SKEW_MS = 30_000L;
private static final long PUSH_CALL_TTL_MS = 10_000L;
private static final String SIGNAL_CALL_INVITE = "call_invite";
private static final String SIGNAL_CALL_ACCEPT = "call_accept";
private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy";
private static final String SIGNAL_CALL_TIMEOUT = "call_timeout";
private static final String SIGNAL_CALL_HANGUP = "call_hangup";
private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
}
if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key");
}
if (!clientSignatureB64.isBlank()) {
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
@@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
}
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
if (targets.isEmpty()) {
boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType);
boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType);
boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode)
&& (SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|| SIGNAL_CALL_HANGUP.equals(signalType));
if (targets.isEmpty() && !isCallInviteAllSessions) {
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
return NetExceptionResponseFactory.error(req, 404, code, msg);
@@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
}
}
if (deliveredSessionIds.isEmpty()) {
int webPushDelivered = 0;
if (isCallInviteAllSessions) {
webPushDelivered = sendIncomingCallPushToOfflineSessions(
toLogin,
fromLogin,
fromSessionId,
signalRequestId,
deliveredSessionIds
);
}
if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) {
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
}
if (isCallAcceptSingleSession) {
notifyStopOnOtherSessions(
fromLogin,
fromSessionId,
fromLogin,
fromSessionId,
signalRequestId,
"accepted_on_other_device"
);
}
if (isCallTerminalSingleSession) {
String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0);
String reason = "terminal_call_signal_" + signalType;
notifyStopOnOtherSessions(
fromLogin,
fromSessionId,
fromLogin,
fromSessionId,
signalRequestId,
reason
);
notifyStopOnOtherSessions(
toLogin,
deliveredTargetSessionId,
fromLogin,
fromSessionId,
signalRequestId,
reason
);
}
Net_SendSignal_Response resp = new Net_SendSignal_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setDeliveredCount(deliveredSessionIds.size());
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
resp.setDeliveredSessionIds(deliveredSessionIds);
resp.setDeliveredWsSessions(deliveredSessionIds.size());
resp.setDeliveredFcmSessions(webPushDelivered);
resp.setDeliveredWebPushSessions(webPushDelivered);
return resp;
}
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
+ dataSha256B64;
}
private static boolean isCallSignalType(String signalType) {
return SIGNAL_CALL_INVITE.equals(signalType)
|| "call_ringing".equals(signalType)
|| SIGNAL_CALL_ACCEPT.equals(signalType)
|| SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|| SIGNAL_CALL_HANGUP.equals(signalType)
|| SIGNAL_CALL_CONNECT_START.equals(signalType)
|| "call_offer".equals(signalType)
|| "call_answer".equals(signalType)
|| "call_ice".equals(signalType);
}
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
return targets;
}
private int sendIncomingCallPushToOfflineSessions(
String toLogin,
String fromLogin,
String fromSessionId,
String callId,
List<String> deliveredOnlineSessionIds
) throws Exception {
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin);
long sentAtMs = System.currentTimeMillis();
long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS;
int delivered = 0;
for (ActiveSessionEntry session : persistedSessions) {
String sessionId = safe(session.getSessionId());
if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) {
continue;
}
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
continue;
}
String payload = "{\"kind\":\"incoming_call\""
+ ",\"title\":\"SHiNE: входящий звонок\""
+ ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(toLogin) + "\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ ",\"expiresAtMs\":" + expiresAtMs
+ "}";
boolean pushed = WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
payload
);
if (pushed) {
delivered++;
}
}
return delivered;
}
private void notifyStopOnOtherSessions(
String targetLogin,
String excludeSessionId,
String fromLogin,
String fromSessionId,
String callId,
String reason
) throws Exception {
if (isBlank(targetLogin) || isBlank(callId)) {
return;
}
Set<String> onlineSessionIds = new java.util.HashSet<>();
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
for (ConnectionContext siblingCtx : sameUserSessions) {
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
onlineSessionIds.add(safe(siblingCtx.getSessionId()));
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
ObjectNode siblingPayload = MAPPER.createObjectNode();
siblingPayload.put("eventId", siblingEventId);
siblingPayload.put("fromLogin", fromLogin);
siblingPayload.put("fromSessionId", fromSessionId);
siblingPayload.put("toLogin", targetLogin);
siblingPayload.put("targetMode", TARGET_MODE_SINGLE);
siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId()));
siblingPayload.put("signalType", SIGNAL_CALL_HANGUP);
siblingPayload.put("signalRequestId", callId);
siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}");
siblingPayload.put("timeMs", System.currentTimeMillis());
WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload);
}
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
long sentAtMs = System.currentTimeMillis();
for (ActiveSessionEntry session : persistedSessions) {
String sessionId = safe(session.getSessionId());
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) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ "}";
WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
pushPayload
);
}
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.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();
}
}
@@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
private String toLogin;
private String callId;
private Integer type;
private String data;
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
@@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
public Integer getType() { return type; }
public void setType(Integer type) { this.type = type; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}
@@ -8,6 +8,9 @@ import java.util.List;
public class Net_SendSignal_Response extends Net_Response {
private int deliveredCount;
private List<String> deliveredSessionIds = new ArrayList<>();
private int deliveredWsSessions;
private int deliveredFcmSessions;
private int deliveredWebPushSessions;
public int getDeliveredCount() {
return deliveredCount;
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
this.deliveredSessionIds = deliveredSessionIds;
}
public int getDeliveredWsSessions() {
return deliveredWsSessions;
}
public void setDeliveredWsSessions(int deliveredWsSessions) {
this.deliveredWsSessions = deliveredWsSessions;
}
public int getDeliveredFcmSessions() {
return deliveredFcmSessions;
}
public void setDeliveredFcmSessions(int deliveredFcmSessions) {
this.deliveredFcmSessions = deliveredFcmSessions;
}
public int getDeliveredWebPushSessions() {
return deliveredWebPushSessions;
}
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) {
this.deliveredWebPushSessions = deliveredWebPushSessions;
}
}
@@ -1057,12 +1057,16 @@ public final class PostgresStorageRepository
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -1100,12 +1104,16 @@ public final class PostgresStorageRepository
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
+1
View File
@@ -49,6 +49,7 @@
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload.
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
### dao_запуск
@@ -0,0 +1,89 @@
# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key`
## Зачем
Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`.
При этом в коде всё ещё живут старые call-like транспорты:
- `CallInviteBroadcast`
- `CallSignalToSession`
Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`.
Отдельный хвост остался в ESP32 homeserver/wallet-сценариях:
- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`;
- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`;
- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку.
## Что уже есть в коде
- универсальный серверный transport `SendSignal` уже существует и поддерживает:
- `single_session`;
- `all_sessions`.
- web UI уже использует `SendSignal` в части межсессионных сценариев.
- звонки пока ещё используют старые call-specific методы.
- ESP32 homeserver main использует:
- `CallSignalToSession` для wallet RPC response;
- `SendSignal` для части технических ответов.
- в ESP32 есть код генерации:
- `sessionSignatureB64`;
- `clientSignatureB64`;
но для некоторых send-signal вызовов `client key` сейчас не обязателен.
## Что сделать
1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`.
2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`.
3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE.
4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`.
5. После этого зачистить legacy call-like транспорт там, где он больше не нужен.
6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на:
- `IncomingCallSignal`;
- `IncomingCallInvite`;
там, где должен работать общий `IncomingSignal`.
## Отдельно про шифрование
Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload.
Предварительный вывод по текущей архитектуре:
- для сервера это почти не должно требовать специальной логики;
- сервер в `SendSignal` в основном:
- валидирует подписи;
- маршрутизирует событие в нужные сессии;
- пересылает `data` дальше.
Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока:
- формат `data` остаётся строковым/JSON-совместимым;
- сервер продолжает считать `SHA-256` от фактической передаваемой строки;
- подписи строятся по уже зашифрованному payload.
То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации.
## Что важно учесть при миграции
- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком;
- сначала перевести ESP32 и wallet-extension на единый `SendSignal`;
- только потом убирать старые call-specific методы;
- не оставлять “особый ESP32-режим” без `client key`;
- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки.
## Откуда продолжать
- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
- от текущего ESP32 homeserver-скетча:
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
- от web UI auth/call transport логики:
- `shine-UI/js/services/auth-service.js`
- `shine-UI/js/services/call-service.js`
## Какие документы потом обновить
- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
- `TODO/README.md`;
- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract;
- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.3.1
server.version=1.3.1
client.version=1.5.35
server.version=1.4.11
+3 -3
View File
@@ -1,3 +1,3 @@
backup.schema.version=1
backup.full.version=2
last.full.backup.date=2026-07-10
backup.schema.version=2
backup.full.version=3
last.full.backup.date=2026-08-08
@@ -1,17 +1,15 @@
cld9-012186
p702072.kvmvps
---
Linux cld9-012186 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
Linux p702072.kvmvps 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
---
Filesystem Size Used Avail Use% Mounted on
tmpfs 392M 1.2M 391M 1% /run
/dev/vda2 32G 14G 17G 46% /
tmpfs 2.0G 0 2.0G 0% /dev/shm
tmpfs 197M 1.2M 196M 1% /run
/dev/sda1 40G 6.7G 31G 18% /
tmpfs 982M 0 982M 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 392M 12K 392M 1% /run/user/1002
tmpfs 197M 0 197M 0% /run/user/1000
---
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00
sr1
vda
├─vda1
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
sda
└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
sr0
@@ -1,13 +1,48 @@
UNIT FILE STATE PRESET
agent-memory.service enabled enabled
caddy.service enabled enabled
containerd.service enabled enabled
coturn.service enabled enabled
docker.service enabled enabled
elaira-agent.service enabled enabled
hermes-dashboard.service enabled enabled
hermes-gateway.service enabled enabled
shine-server.service enabled enabled
ubuntu-fan.service enabled enabled
UNIT FILE STATE VENDOR PRESET
apparmor.service enabled enabled
blk-availability.service enabled enabled
caddy.service enabled enabled
console-setup.service enabled enabled
containerd.service enabled enabled
coturn.service enabled enabled
cron.service enabled enabled
dmesg.service enabled enabled
docker.service enabled enabled
e2scrub_reap.service enabled enabled
finalrd.service enabled enabled
getty@.service enabled enabled
gpu-manager.service enabled enabled
grub-common.service enabled enabled
grub-initrd-fallback.service enabled enabled
guestfs-firstboot.service enabled enabled
irqbalance.service enabled enabled
keyboard-setup.service enabled enabled
lvm2-monitor.service enabled enabled
lxd-agent.service enabled enabled
ModemManager.service enabled enabled
multipathd.service enabled enabled
networkd-dispatcher.service enabled enabled
open-iscsi.service enabled enabled
open-vm-tools.service enabled enabled
pollinate.service enabled enabled
rsyslog.service enabled enabled
secureboot-db.service enabled enabled
setvtrgb.service enabled enabled
shine-server.service enabled enabled
snap.lxd.activate.service enabled enabled
ssh.service enabled enabled
systemd-networkd-wait-online.service enabled disabled
systemd-networkd.service enabled enabled
systemd-pstore.service enabled enabled
systemd-resolved.service enabled enabled
systemd-timesyncd.service enabled enabled
thermald.service enabled enabled
ua-reboot-cmds.service enabled enabled
ubuntu-advantage.service enabled enabled
ubuntu-fan.service enabled enabled
udisks2.service enabled enabled
ufw.service enabled enabled
unattended-upgrades.service enabled enabled
vgauth.service enabled enabled
10 unit files listed.
45 unit files listed.
@@ -1,20 +1,20 @@
UNIT LOAD ACTIVE SUB DESCRIPTION
agent-memory.service loaded active running agent-memory service
caddy.service loaded active running Caddy
containerd.service loaded active running containerd container runtime
coturn.service loaded active running coTURN STUN/TURN Server
cron.service loaded active running Regular background program processing daemon
dbus.service loaded active running D-Bus System Message Bus
docker.service loaded active running Docker Application Container Engine
elaira-agent.service loaded active running Elaira self-hosted agent
getty@tty1.service loaded active running Getty on tty1
hermes-gateway.service loaded active running Hermes Agent Gateway - Messaging Platform Integration
irqbalance.service loaded active running irqbalance daemon
ModemManager.service loaded active running Modem Manager
multipathd.service loaded active running Device-Mapper Multipath Device Controller
networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd
packagekit.service loaded active running PackageKit Daemon
polkit.service loaded active running Authorization Manager
qemu-guest-agent.service loaded active running QEMU Guest Agent
rsyslog.service loaded active running System Logging Service
shine-server.service loaded active running SHiNE Server
shine-server.service loaded active running SHiNE Server (shineup.me)
ssh.service loaded active running OpenBSD Secure Shell server
systemd-hostnamed.service loaded active running Hostname Service
systemd-journald.service loaded active running Journal Service
@@ -26,10 +26,9 @@
udisks2.service loaded active running Disk Manager
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
upower.service loaded active running Daemon for power management
user@1002.service loaded active running User Manager for UID 1002
Legend: LOAD → Reflects whether the unit definition was properly loaded.
ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
SUB → The low-level unit activation state, values depend on unit type.
user@1000.service loaded active running User Manager for UID 1000
LOAD = Reflects whether the unit definition was properly loaded.
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
SUB = The low-level unit activation state, values depend on unit type.
28 loaded units listed.
@@ -1,32 +1,12 @@
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=15))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23))
LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6))
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14))
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=17))
LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=8410,fd=7))
LISTEN 0 4096 127.0.0.1:45999 0.0.0.0:* users:(("containerd",pid=2121968,fd=15))
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=8433,fd=7))
LISTEN 0 2048 127.0.0.1:9119 0.0.0.0:* users:(("hermes",pid=1913356,fd=7))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=323588,fd=3),("systemd",pid=1,fd=130))
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=40))
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=17))
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=56))
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=19))
LISTEN 0 100 *:8018 *:* users:(("java",pid=9688,fd=13))
LISTEN 0 50 *:7070 *:* users:(("java",pid=1732508,fd=16))
LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=8417,fd=7))
LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=323588,fd=4),("systemd",pid=1,fd=132))
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=8096,fd=16))
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=8096,fd=15))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=25))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=62))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=27))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=64))
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3))
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34))
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33))
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4))
LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16))
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7))
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9))
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4))
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7))
LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19))
@@ -1,2 +1,2 @@
NAMES IMAGE STATUS PORTS
gitea gitea/gitea:1.22.6 Up 5 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp
NAMES IMAGE STATUS PORTS
shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp
@@ -1,8 +1 @@
4.0K /home/player/hosts.codex.test
8.0K /home/player/Work_AGENTS
804K /home/player/sites
33M /home/player/elaira-agent
36M /home/player/agent-memory
46M /home/player/gitea
183M /home/player/SHiNE
1.7G /home/player/hermes
85M /home/player/SHiNE
@@ -2,11 +2,10 @@
4.0K /var/local
4.0K /var/mail
4.0K /var/opt
4.0K /var/snap
16K /var/spool
88K /var/tmp
3.2M /var/backups
143M /var/cache
1.2G /var/lib
1.5G /var/log
72K /var/tmp
1.9M /var/backups
319M /var/cache
1.1G /var/log
1.5G /var/lib
2.8G /var
@@ -1 +1 @@
2026-07-10T08:27:36Z
2026-08-08T20:05:20Z
@@ -1,8 +1,5 @@
openmindsoft.io, www.openmindsoft.io {
encode zstd gzip
root * /home/player/sites/OpenMindSoft.io
try_files {path} /index.html
file_server
{
auto_https disable_redirects
}
shineup.me {
@@ -46,33 +43,3 @@ shineup.me {
}
}
}
git.shineup.me {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
test-solana-tickets.shineup.me, test-solana-tickets.shiningpeople.ru {
encode zstd gzip
root * /home/player/sites/test-solana-tickets.shineup.me
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
hermes.shineup.me {
encode zstd gzip
basicauth {
player $2a$14$a41XVsBhgxgKN2MVS5Vt3Otu4C6mmv2FRo1gYDjvPDEYwYkPGnj1e
}
reverse_proxy 127.0.0.1:9119 {
header_up Host 127.0.0.1:9119
header_up Origin http://127.0.0.1:9119
}
}
@@ -0,0 +1 @@
missing: /etc/systemd/system/agent-memory.service
@@ -1,5 +1,5 @@
[Unit]
Description=SHiNE Server
Description=SHiNE Server (shineup.me)
After=network.target
[Service]
@@ -7,7 +7,7 @@ Type=simple
User=player
Group=player
WorkingDirectory=/home/player/SHiNE/shine-server
ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
ExecStart=/usr/bin/java -Dserver.port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
Restart=always
RestartSec=3
@@ -1,709 +1,17 @@
# Coturn TURN SERVER configuration file
#
# Boolean values note: where boolean value is supposed to be used,
# you can use '0', 'off', 'no', 'false', 'f' as 'false,
# and you can use '1', 'on', 'yes', 'true', 't' as 'true'
# If the value is missed, then it means 'true'.
#
# Listener interface device (optional, Linux only).
# NOT RECOMMENDED.
#
#listening-device=eth0
# TURN listener port for UDP and TCP (Default: 3478).
# Note: actually, TLS & DTLS sessions can connect to the
# "plain" TCP & UDP port(s), too - if allowed by configuration.
#
#listening-port=3478
# TURN listener port for TLS (Default: 5349).
# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS
# port(s), too - if allowed by configuration. The TURN server
# "automatically" recognizes the type of traffic. Actually, two listening
# endpoints (the "plain" one and the "tls" one) are equivalent in terms of
# functionality; but we keep both endpoints to satisfy the RFC 5766 specs.
# For secure TCP connections, we currently support SSL version 3 and
# TLS version 1.0, 1.1 and 1.2.
# For secure UDP connections, we support DTLS version 1.
#
#tls-listening-port=5349
# Alternative listening port for UDP and TCP listeners;
# default (or zero) value means "listening port plus one".
# This is needed for RFC 5780 support
# (STUN extension specs, NAT behavior discovery). The TURN Server
# supports RFC 5780 only if it is started with more than one
# listening IP address of the same family (IPv4 or IPv6).
# RFC 5780 is supported only by UDP protocol, other protocols
# are listening to that endpoint only for "symmetry".
#
#alt-listening-port=0
# Alternative listening port for TLS and DTLS protocols.
# Default (or zero) value means "TLS listening port plus one".
#
#alt-tls-listening-port=0
# Listener IP address of relay server. Multiple listeners can be specified.
# If no IP(s) specified in the config file or in the command line options,
# then all IPv4 and IPv6 system IPs will be used for listening.
#
#listening-ip=172.17.19.101
#listening-ip=10.207.21.238
#listening-ip=2607:f0d0:1002:51::4
# Auxiliary STUN/TURN server listening endpoint.
# Aux servers have almost full TURN and STUN functionality.
# The (minor) limitations are:
#
# 1) Auxiliary servers do not have alternative ports and
# they do not support STUN RFC 5780 functionality (CHANGE REQUEST).
#
# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply.
#
# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6.
#
# There may be multiple aux-server options, each will be used for listening
# to client requests.
#
#aux-server=172.17.19.110:33478
#aux-server=[2607:f0d0:1002:51::4]:33478
# (recommended for older Linuxes only)
# Automatically balance UDP traffic over auxiliary servers (if configured).
# The load balancing is using the ALTERNATE-SERVER mechanism.
# The TURN client must support 300 ALTERNATE-SERVER response for this
# functionality.
#
#udp-self-balance
# Relay interface device for relay sockets (optional, Linux only).
# NOT RECOMMENDED.
#
#relay-device=eth1
# Relay address (the local IP address that will be used to relay the
# packets to the peer).
# Multiple relay addresses may be used.
# The same IP(s) can be used as both listening IP(s) and relay IP(s).
#
# If no relay IP(s) specified, then the turnserver will apply the default
# policy: it will decide itself which relay addresses to be used, and it
# will always be using the client socket IP address as the relay IP address
# of the TURN session (if the requested relay address family is the same
# as the family of the client socket).
#
#relay-ip=172.17.19.105
#relay-ip=2607:f0d0:1002:51::5
# For Amazon EC2 users:
#
# TURN Server public/private address mapping, if the server is behind NAT.
# In that situation, if a -X is used in form "-X <ip>" then that ip will be reported
# as relay IP address of all allocations. This scenario works only in a simple case
# when one single relay address is be used, and no RFC5780 functionality is required.
# That single relay address must be mapped by NAT to the 'external' IP.
# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field.
# For that 'external' IP, NAT must forward ports directly (relayed port 12345
# must be always mapped to the same 'external' port 12345).
#
# In more complex case when more than one IP address is involved,
# that option must be used several times, each entry must
# have form "-X <public-ip/private-ip>", to map all involved addresses.
# RFC5780 NAT discovery STUN functionality will work correctly,
# if the addresses are mapped properly, even when the TURN server itself
# is behind A NAT.
#
# By default, this value is empty, and no address mapping is used.
#
#external-ip=60.70.80.91
#
#OR:
#
#external-ip=60.70.80.91/172.17.19.101
#external-ip=60.70.80.92/172.17.19.102
# Number of the relay threads to handle the established connections
# (in addition to authentication thread and the listener thread).
# If explicitly set to 0 then application runs relay process in a
# single thread, in the same thread with the listener process
# (the authentication thread will still be a separate thread).
#
# If this parameter is not set, then the default OS-dependent
# thread pattern algorithm will be employed. Usually the default
# algorithm is the most optimal, so you have to change this option
# only if you want to make some fine tweaks.
#
# In the older systems (Linux kernel before 3.9),
# the number of UDP threads is always one thread per network listening
# endpoint - including the auxiliary endpoints - unless 0 (zero) or
# 1 (one) value is set.
#
#relay-threads=0
# Lower and upper bounds of the UDP relay endpoints:
# (default values are 49152 and 65535)
#
#min-port=49152
#max-port=65535
# Uncomment to run TURN server in 'normal' 'moderate' verbose mode.
# By default the verbose mode is off.
#verbose
# Uncomment to run TURN server in 'extra' verbose mode.
# This mode is very annoying and produces lots of output.
# Not recommended under any normal circumstances.
#
#Verbose
# Uncomment to use fingerprints in the TURN messages.
# By default the fingerprints are off.
#
#fingerprint
# Uncomment to use long-term credential mechanism.
# By default no credentials mechanism is used (any user allowed).
#
#lt-cred-mech
# This option is opposite to lt-cred-mech.
# (TURN Server with no-auth option allows anonymous access).
# If neither option is defined, and no users are defined,
# then no-auth is default. If at least one user is defined,
# in this file or in command line or in usersdb file, then
# lt-cred-mech is default.
#
#no-auth
# TURN REST API flag.
# (Time Limited Long Term Credential)
# Flag that sets a special authorization option that is based upon authentication secret.
#
# This feature's purpose is to support "TURN Server REST API", see
# "TURN REST API" link in the project's page
# https://github.com/coturn/coturn/
#
# This option is used with timestamp:
#
# usercombo -> "timestamp:userid"
# turn user -> usercombo
# turn password -> base64(hmac(secret key, usercombo))
#
# This allows TURN credentials to be accounted for a specific user id.
# If you don't have a suitable id, the timestamp alone can be used.
# This option is just turning on secret-based authentication.
# The actual value of the secret is defined either by option static-auth-secret,
# or can be found in the turn_secret table in the database (see below).
#
# Read more about it:
# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf
#
# Be aware that use-auth-secret overrides some part of lt-cred-mech.
# Notice that this feature depends internally on lt-cred-mech, so if you set
# use-auth-secret then it enables internally automatically lt-cred-mech option
# like if you enable both.
#
# You can use only one of the to auth mechanisms in the same time because,
# both mechanism use the username and password validation in different way.
#
# This way be aware that you can't use both auth mechnaism in the same time!
# Use in config either the lt-cred-mech or the use-auth-secret
# to avoid any confusion.
#
listening-port=3478
fingerprint
lt-cred-mech
use-auth-secret
# 'Static' authentication secret value (a string) for TURN REST API only.
# If not set, then the turn server
# will try to use the 'dynamic' value in turn_secret table
# in user database (if present). The database-stored value can be changed on-the-fly
# by a separate program, so this is why that other mode is 'dynamic'.
#
#static-auth-secret=north
# Server name used for
# the oAuth authentication purposes.
# The default value is the realm name.
#
#server-name=blackdow.carleon.gov
# Flag that allows oAuth authentication.
#
#oauth
# 'Static' user accounts for long term credentials mechanism, only.
# This option cannot be used with TURN REST API.
# 'Static' user accounts are NOT dynamically checked by the turnserver process,
# so that they can NOT be changed while the turnserver is running.
#
#user=username1:key1
#user=username2:key2
# OR:
#user=username1:password1
#user=username2:password2
#
# Keys must be generated by turnadmin utility. The key value depends
# on user name, realm, and password:
#
# Example:
# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic
# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee
# ('0x' in the beginning of the key is what differentiates the key from
# password. If it has 0x then it is a key, otherwise it is a password).
#
# The corresponding user account entry in the config file will be:
#
#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee
# Or, equivalently, with open clear password (less secure):
#user=ninefingers:youhavetoberealistic
#
# SQLite database file name.
#
# Default file name is /var/db/turndb or /usr/local/var/db/turndb or
# /var/lib/turn/turndb.
#
#userdb=/var/db/turndb
# PostgreSQL database connection string in the case that we are using PostgreSQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL
# versions connection string format, see
# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING
# for 9.x and newer connection string formats.
#
#psql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> connect_timeout=30"
# MySQL database connection string in the case that we are using MySQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
#
# Optional connection string parameters for the secure communications (SSL):
# ca, capath, cert, key, cipher
# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the
# command options description).
#
# Use string format as below (space separated parameters, all optional):
#
#mysql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
# If you want to use in the MySQL connection string the password in encrypted format,
# then set in this option the MySQL password encryption secret key file.
#
# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format!
# If you want to use cleartext password then do not set this option!
#
# This is the file path which contain secret key of aes encryption while using password encryption.
#
#secret-key-file=/path/
# MongoDB database connection string in the case that we are using MongoDB
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html
#
#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"
# Redis database connection string in the case that we are using Redis
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format as below (space separated parameters, all optional):
#
#redis-userdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used).
# This database keeps allocations status information, and it can be also used for publishing
# and delivering traffic and allocation event notifications.
# The connection string has the same parameters as redis-userdb connection string.
# Use string format as below (space separated parameters, all optional):
#
#redis-statsdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# The default realm to be used for the users when no explicit
# origin/realm relationship was found in the database, or if the TURN
# server is not using any database (just the commands-line settings
# and the userdb file). Must be used with long-term credentials
# mechanism or with TURN REST API.
#
# Note: If default realm is not specified at all, then realm falls back to the host domain name.
# If domain name is empty string, or '(None)', then it is initialized to am empty string.
#
#realm=mycompany.org
# The flag that sets the origin consistency
# check: across the session, all requests must have the same
# main ORIGIN attribute value (if the ORIGIN was
# initially used by the session).
#
#check-origin-consistency
# Per-user allocation quota.
# default value is 0 (no quota, unlimited number of sessions per user).
# This option can also be set through the database, for a particular realm.
#
#user-quota=0
# Total allocation quota.
# default value is 0 (no quota).
# This option can also be set through the database, for a particular realm.
#
#total-quota=0
# Max bytes-per-second bandwidth a TURN session is allowed to handle
# (input and output network streams are treated separately). Anything above
# that limit will be dropped or temporary suppressed (within
# the available buffer limits).
# This option can also be set through the database, for a particular realm.
#
#max-bps=0
#
# Maximum server capacity.
# Total bytes-per-second bandwidth the TURN server is allowed to allocate
# for the sessions, combined (input and output network streams are treated separately).
#
# bps-capacity=0
# Uncomment if no UDP client listener is desired.
# By default UDP client listener is always started.
#
#no-udp
# Uncomment if no TCP client listener is desired.
# By default TCP client listener is always started.
#
#no-tcp
# Uncomment if no TLS client listener is desired.
# By default TLS client listener is always started.
#
#no-tls
# Uncomment if no DTLS client listener is desired.
# By default DTLS client listener is always started.
#
#no-dtls
# Uncomment if no UDP relay endpoints are allowed.
# By default UDP relay endpoints are enabled (like in RFC 5766).
#
#no-udp-relay
# Uncomment if no TCP relay endpoints are allowed.
# By default TCP relay endpoints are enabled (like in RFC 6062).
#
#no-tcp-relay
# Uncomment if extra security is desired,
# with nonce value having limited lifetime.
# By default, the nonce value is unique for a session,
# and has unlimited lifetime.
# Set this option to limit the nonce lifetime.
# It defaults to 600 secs (10 min) if no value is provided. After that delay,
# the client will get 438 error and will have to re-authenticate itself.
#
#stale-nonce=600
# Uncomment if you want to set the maximum allocation
# time before it has to be refreshed.
# Default is 3600s.
#
#max-allocate-lifetime=3600
# Uncomment to set the lifetime for the channel.
# Default value is 600 secs (10 minutes).
# This value MUST not be changed for production purposes.
#
#channel-lifetime=600
# Uncomment to set the permission lifetime.
# Default to 300 secs (5 minutes).
# In production this value MUST not be changed,
# however it can be useful for test purposes.
#
#permission-lifetime=300
# Certificate file.
# Use an absolute path or path relative to the
# configuration file.
#
#cert=/usr/local/etc/turn_server_cert.pem
# Private key file.
# Use an absolute path or path relative to the
# configuration file.
# Use PEM file format.
#
#pkey=/usr/local/etc/turn_server_pkey.pem
# Private key file password, if it is in encoded format.
# This option has no default value.
#
#pkey-pwd=...
# Allowed OpenSSL cipher list for TLS/DTLS connections.
# Default value is "DEFAULT".
#
#cipher-list="DEFAULT"
# CA file in OpenSSL format.
# Forces TURN server to verify the client SSL certificates.
# By default it is not set: there is no default value and the client
# certificate is not checked.
#
# Example:
#CA-file=/etc/ssh/id_rsa.cert
# Curve name for EC ciphers, if supported by OpenSSL
# library (TLS and DTLS). The default value is prime256v1,
# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+,
# an optimal curve will be automatically calculated, if not defined
# by this option.
#
#ec-curve-name=prime256v1
# Use 566 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh566
# Use 2066 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh2066
# Use custom DH TLS key, stored in PEM format in the file.
# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file.
#
#dh-file=<DH-PEM-file-name>
# Flag to prevent stdout log messages.
# By default, all log messages are going to both stdout and to
# the configured log file. With this option everything will be
# going to the configured log only (unless the log file itself is stdout).
#
#no-stdout-log
# Option to set the log file name.
# By default, the turnserver tries to open a log file in
# /var/log, /var/tmp, /tmp and current directories directories
# (which open operation succeeds first that file will be used).
# With this option you can set the definite log file name.
# The special names are "stdout" and "-" - they will force everything
# to the stdout. Also, the "syslog" name will force everything to
# the system log (syslog).
# In the runtime, the logfile can be reset with the SIGHUP signal
# to the turnserver process.
#
#log-file=/var/tmp/turn.log
# Option to redirect all log output into system log (syslog).
#
syslog
# This flag means that no log file rollover will be used, and the log file
# name will be constructed as-is, without PID and date appendage.
# This option can be used, for example, together with the logrotate tool.
#
#simple-log
# Option to set the "redirection" mode. The value of this option
# will be the address of the alternate server for UDP & TCP service in form of
# <ip>[:<port>]. The server will send this value in the attribute
# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client.
# Client will receive only values with the same address family
# as the client network endpoint address family.
# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description.
# The client must use the obtained value for subsequent TURN communications.
# If more than one --alternate-server options are provided, then the functionality
# can be more accurately described as "load-balancing" than a mere "redirection".
# If the port number is omitted, then the default port
# number 3478 for the UDP/TCP protocols will be used.
# Colon (:) characters in IPv6 addresses may conflict with the syntax of
# the option. To alleviate this conflict, literal IPv6 addresses are enclosed
# in square brackets in such resource identifiers, for example:
# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 .
# Multiple alternate servers can be set. They will be used in the
# round-robin manner. All servers in the pool are considered of equal weight and
# the load will be distributed equally. For example, if we have 4 alternate servers,
# then each server will receive 25% of ALLOCATE requests. A alternate TURN server
# address can be used more than one time with the alternate-server option, so this
# can emulate "weighting" of the servers.
#
# Examples:
#alternate-server=1.2.3.4:5678
#alternate-server=11.22.33.44:56789
#alternate-server=5.6.7.8
#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to set alternative server for TLS & DTLS services in form of
# <ip>:<port>. If the port number is omitted, then the default port
# number 5349 for the TLS/DTLS protocols will be used. See the previous
# option for the functionality description.
#
# Examples:
#tls-alternate-server=1.2.3.4:5678
#tls-alternate-server=11.22.33.44:56789
#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to suppress TURN functionality, only STUN requests will be processed.
# Run as STUN server only, all TURN requests will be ignored.
# By default, this option is NOT set.
#
#stun-only
# Option to suppress STUN functionality, only TURN requests will be processed.
# Run as TURN server only, all STUN requests will be ignored.
# By default, this option is NOT set.
#
#no-stun
# This is the timestamp/username separator symbol (character) in TURN REST API.
# The default value is ':'.
# rest-api-separator=:
# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1).
# This is an extra security measure.
#
# (To avoid any security issue that allowing loopback access may raise,
# the no-loopback-peers option is replaced by allow-loopback-peers.)
#
# Allow it only for testing in a development environment!
# In production it adds a possible security vulnerability, so for security reasons
# it is not allowed using it together with empty cli-password.
#
#allow-loopback-peers
# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*).
# This is an extra security measure.
#
#no-multicast-peers
# Option to set the max time, in seconds, allowed for full allocation establishment.
# Default is 60 seconds.
#
#max-allocate-timeout=60
# Option to allow or ban specific ip addresses or ranges of ip addresses.
# If an ip address is specified as both allowed and denied, then the ip address is
# considered to be allowed. This is useful when you wish to ban a range of ip
# addresses, except for a few specific ips within that range.
#
# This can be used when you do not want users of the turn server to be able to access
# machines reachable by the turn server, but would otherwise be unreachable from the
# internet (e.g. when the turn server is sitting behind a NAT)
#
# Examples:
# denied-peer-ip=83.166.64.0-83.166.95.255
# allowed-peer-ip=83.166.68.45
# File name to store the pid of the process.
# Default is /var/run/turnserver.pid (if superuser account is used) or
# /var/tmp/turnserver.pid .
#
#pidfile="/var/run/turnserver.pid"
# Require authentication of the STUN Binding request.
# By default, the clients are allowed anonymous access to the STUN Binding functionality.
#
#secure-stun
# Mobility with ICE (MICE) specs support.
#
#mobility
# Allocate Address Family according
# If enabled then TURN server allocates address family according the TURN
# Client <=> Server communication address family.
# (By default coTURN works according RFC 6156.)
# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!!
#
#keep-address-family
# User name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current user ID to that user.
#
#proc-user=<user-name>
# Group name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current group ID to that group.
#
#proc-group=<group-name>
# Turn OFF the CLI support.
# By default it is always ON.
# See also options cli-ip and cli-port.
#
#no-cli
#Local system IP address to be used for CLI server endpoint. Default value
# is 127.0.0.1.
#
#cli-ip=127.0.0.1
# CLI server port. Default is 5766.
#
#cli-port=5766
# CLI access password. Default is empty (no password).
# For the security reasons, it is recommended to use the encrypted
# for of the password (see the -P command in the turnadmin utility).
#
# Secure form for password 'qwerty':
#
#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a
#
# Or unsecure form for the same password:
#
#cli-password=qwerty
# Enable Web-admin support on https. By default it is Disabled.
# If it is enabled it also enables a http a simple static banner page
# with a small reminder that the admin page is available only on https.
#
#web-admin
# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1.
#
#web-admin-ip=127.0.0.1
# Web-admin server port. Default is 8080.
#
#web-admin-port=8080
# Web-admin server listen on STUN/TURN worker threads
# By default it is disabled for security resons! (Not recommended in any production environment!)
#
#web-admin-listen-on-workers
# Server relay. NON-STANDARD AND DANGEROUS OPTION.
# Only for those applications when we want to run
# server applications on the relay endpoints.
# This option eliminates the IP permissions check on
# the packets incoming to the relay endpoints.
#
#server-relay
# Maximum number of output sessions in ps CLI command.
# This value can be changed on-the-fly in CLI. The default value is 256.
#
#cli-max-output-sessions
# Set network engine type for the process (for internal purposes).
#
#ne=[1|2|3]
# Do not allow an TLS/DTLS version of protocol
#
#no-tlsv1
#no-tlsv1_1
#no-tlsv1_2
static-auth-secret=<secret-on-server-only>
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
realm=turn1.shineup.me
total-quota=200
stale-nonce=600
no-multicast-peers
no-loopback-peers
no-cli
simple-log
external-ip=178.208.64.62
listening-ip=0.0.0.0
relay-ip=178.208.64.62
min-port=49000
max-port=53999
@@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CAP_DIR="${SCHEME_DIR}/captures"
CFG_DIR="${SCHEME_DIR}/configs"
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
copy_optional_file() {
local remote_path="$1"
local local_path="$2"
if ssh "${REMOTE_HOST}" "test -f '$remote_path'"; then
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}"
else
mkdir -p "$(dirname "${local_path}")"
echo "missing: ${remote_path}" > "${local_path}.MISSING.txt"
fi
}
echo "[1/3] Обновляю инвентарь"
ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt"
ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt"
@@ -18,10 +30,14 @@ ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR}
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
echo "[2/3] Обновляю ключевые конфиги"
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
else
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
fi
copy_optional_file "/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
copy_optional_file "/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/shine-server.service"
copy_optional_file "/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/agent-memory.service"
echo "[3/3] Метка обновления"
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
+1
View File
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="server2shineupme" \
DEPLOY_SERVER_ADDRESS="server2.shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \
TARGET_URL="https://server2.shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="shineupme" \
DEPLOY_SERVER_ADDRESS="shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \
TARGET_URL="https://shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
+76 -5
View File
@@ -88,6 +88,10 @@
- `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации
- `entrypoint_edit_forbidden``TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
- `status_confirmed_target_must_be_status_action``STATUS_CONFIRMED` должен ссылаться на статусный блок
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
@@ -103,7 +107,13 @@
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)`формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_RATING (30)`target-based отзыв на конкретный блок
- `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала
- `TEXT_ENTRYPOINT (100)` — входная страница канала
- `TEXT_EXERCISE (110)` — line-based материал упражнения
- `TEXT_SERVICE (120)` — line-based материал услуги / процедуры
- `TEXT_COURSE (130)` — line-based материал курса
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
@@ -133,7 +143,68 @@
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
## 6. Хватает ли функций сейчас
6. **STATUS_ACTION (type=5)**
- `STATUS_DONE_ONCE (10)`
- `STATUS_LEARNED (20)`
- `STATUS_SERVICE_PASSED (30)`
- `STATUS_CONFIRMED (100)`
- `STATUS_INTERESTED (110)`
- `STATUS_STARTED (120)`
- `STATUS_IN_STUDY (130)`
- `STATUS_ABANDONED (140)`
- `STATUS_COMPLETED (150)`
## 6. Практические payload-форматы для каналов и вложений
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
### Вложения в сообщениях
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
Пример текстового содержимого body:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
Пример вложения с отдельным preview-файлом:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
### Создание публичного канала с профилем
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
```text
<SHiNE:title;v=1;Человекочитаемое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Описание канала
```
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
### Изменение профиля канала
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
Текстовое содержимое body использует тот же формат полного снимка профиля:
```text
<SHiNE:title;v=1;Новое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Новое описание канала
```
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
## 7. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
@@ -147,7 +218,7 @@
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков
## 8. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
@@ -157,7 +228,7 @@
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 8. USER_PARAM для «личных данных»
## 9. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**:
@@ -183,7 +254,7 @@
---
## 9. `GetBlockchainBlock`
## 10. `GetBlockchainBlock`
### Назначение
+18 -1
View File
@@ -297,6 +297,7 @@
Первый целевой сценарий:
- `remote AddBlock via homeserver session`
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
То есть телефон без локального `blockchain.key` может:
@@ -336,6 +337,12 @@
- запрос должен быть подписан и `session key`, и `client key`;
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
Для звонковых сигналов `signalType = call_*` правило строже:
- `clientSignatureB64` обязателен;
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
- это правило действует и для `single_session`, и для `all_sessions`.
### Запрос в одну сессию
```json
@@ -366,11 +373,20 @@
"ok": true,
"payload": {
"deliveredCount": 1,
"deliveredSessionIds": ["sess-hs-001"]
"deliveredSessionIds": ["sess-hs-001"],
"deliveredWsSessions": 1,
"deliveredFcmSessions": 0,
"deliveredWebPushSessions": 0
}
}
```
Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае:
- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket;
- `deliveredWebPushSessions` — сколько offline-сессий получили web-push;
- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы.
### Событие на принимающей стороне
```json
@@ -428,6 +444,7 @@
- `404 / USER_NOT_FOUND` — логин адресата не найден.
- `400 / BAD_DATA` — сервер не смог обработать `data`.
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
+76 -3
View File
@@ -14,11 +14,13 @@
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки.
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
5. `ListGroupChats200` — отдает список групповых чатов типа `200`.
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
@@ -53,6 +55,12 @@
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelName": "0",
"displayName": "Мой канал",
"channelDescription": "Короткое описание",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
@@ -141,8 +149,42 @@
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "news",
"displayName": "Новости Bob",
"channelDescription": "Канал о новостях",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"metaEvents": [
{
"kind": "created",
"messageRef": { "blockNumber": 123, "blockHash": "..." },
"authorLogin": "Bob",
"authorBlockchainName": "bob-001",
"lineStep": 1,
"createdAtMs": 1760000000000,
"title": "news",
"description": "",
"avaAr": "",
"avaSha256": "",
"avaSize": 0
},
{
"kind": "profile_updated",
"messageRef": { "blockNumber": 130, "blockHash": "..." },
"authorLogin": "Bob",
"authorBlockchainName": "bob-001",
"lineStep": 2,
"createdAtMs": 1760000500000,
"title": "Новости Bob",
"description": "Канал о новостях",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193
}
],
"messages": [
{
"messageRef": { "blockNumber": 140, "blockHash": "..." },
@@ -152,6 +194,7 @@
"text": "текущая версия",
"likesCount": 12,
"repliesCount": 3,
"ratingsCount": 2,
"versionsTotal": 4,
"versions": [
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
@@ -208,6 +251,36 @@
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит:
- `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`;
- `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`.
- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений:
- `TEXT_REPLY`;
- `TEXT_RATING`.
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
---
## 4) GetPersonalDiary
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
- Вызов доступен только владельцу дневника.
- Сообщения в ответе строятся из блоков `STATUS_ACTION`.
- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие.
### Request
```json
{
"op": "GetPersonalDiary",
"requestId": "req-4",
"payload": {
"login": "Alice",
"limit": 200,
"sort": "asc"
}
}
```
---
+1
View File
@@ -44,6 +44,7 @@
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
@@ -11,14 +11,16 @@
- `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`).
## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
- `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя.
- `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED.
## Примечание
@@ -9,6 +9,23 @@ Payload включает:
4. `channelType` (`uint16`, 2 байта);
5. `channelTypeVersion` (`uint16`, 2 байта).
### `channelDescription`
`channelDescription` хранится как `uint16 length + UTF-8`, максимум `2048` байт.
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
Если `title` отсутствует, человекочитаемым именем считается техническое `channelName`.
Если `avatar` отсутствует, канал создаётся без аватара.
Новые UI-клиенты создают публичный канал одним блоком `TECH_CREATE_CHANNEL`; отдельный `TEXT_CHANNEL_META` при создании не пишется. `TEXT_CHANNEL_META` используется для последующих изменений профиля канала.
## 2. Типы каналов
- `0``stories` (root-канал пользователя).
- `1` — публичный канал.
+10 -24
View File
@@ -1,33 +1,19 @@
# Командные сообщения каналов
## 1. Общий префикс
Командные сообщения распознаются по префиксу:
Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала.
`/.`
## Удалённая команда `/.desc`
Пример:
- `/.desc Новый комментарий канала`
Команда `/.desc <text>` больше не поддерживается и не применяется сервером.
## 2. Поддерживаемые команды
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
### Для всех типов каналов (`0`, `1`, `100`, `200`)
- `/.desc <text>` — смена описания канала.
- `msg_type=1`
- `subType=90`
- `TEXT_CHANNEL_META`
Примечание:
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
### Дополнительно для `type=200`
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
## Зарезервированные команды type=200
Формат аргументов фиксирован: через пробел.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются.
+78 -2
View File
@@ -1,6 +1,6 @@
# TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования.
TEXT-тип хранит сообщения, материалы и редактирования.
## Подтипы
@@ -21,18 +21,94 @@ TEXT-тип хранит сообщения и редактирования.
- target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30``TEXT_REPOST`
5. `subType=30``TEXT_RATING`
- target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала.
6. `subType=50``TEXT_REPOST`
- репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
7. `subType=90``TEXT_CHANNEL_META`
- скрытый технический снимок профиля канала;
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
- не отображается как обычное сообщение ленты;
- применяется сервером к текущему состоянию канала.
8. `subType=100``TEXT_ENTRYPOINT`
- входная страница канала;
- line-based сообщение с тем же body, что у `TEXT_POST`;
- не редактируется через `TEXT_EDIT_POST`: новая версия создаётся новым `TEXT_ENTRYPOINT`.
9. `subType=110``TEXT_EXERCISE`
- line-based материал упражнения;
- использует тот же body, что у `TEXT_POST`.
10. `subType=120``TEXT_SERVICE`
- line-based материал услуги / процедуры;
- использует тот же body, что у `TEXT_POST`.
11. `subType=130``TEXT_COURSE`
- line-based материал курса;
- использует тот же body, что у `TEXT_POST`.
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
Важно:
- `TEXT_EDIT_POST` — технический edit для line-based сообщений канала;
- `TEXT_EDIT_REPLY` — технический edit для reply-сообщений;
- `TEXT_ENTRYPOINT` через `TEXT_EDIT_POST` не редактируется.
## Пустой text в edit
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
Подробная спецификация: [15_TEXT_Attachments.md](./15_TEXT_Attachments.md).
Бинарный формат `TextBody` не меняется: вложения являются частью UTF-8 текста.
Один блок вложения:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Для файлов с отдельным превью клиент может использовать расширенный вариант:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
```
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
Обязательные поля:
- `v=1` или `v=2`
- `name` - имя файла, закодированное через `encodeURIComponent`
- `size` - размер файла в байтах
- `sha256` - SHA-256 исходного файла в hex
- `ar` - короткий Arweave Transaction ID, без полного URL
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
Правила клиента:
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
@@ -0,0 +1,69 @@
# STATUS_ACTION блоки (`type=5`, `version=1`)
`STATUS_ACTION` хранит статусные действия пользователя по отношению к конкретному материалу.
## Подтипы
1. `subType=10``STATUS_DONE_ONCE`
- выполнил упражнение один раз.
2. `subType=20``STATUS_LEARNED`
- изучил упражнение / комплекс.
3. `subType=30``STATUS_SERVICE_PASSED`
- прошёл услугу / процедуру.
4. `subType=100``STATUS_CONFIRMED`
- подтвердил чужой status-блок.
5. `subType=110``STATUS_INTERESTED`
- заинтересовался курсом.
6. `subType=120``STATUS_STARTED`
- начал курс.
7. `subType=130``STATUS_IN_STUDY`
- находится в процессе полноценного изучения курса.
8. `subType=140``STATUS_ABANDONED`
- бросил курс.
9. `subType=150``STATUS_COMPLETED`
- завершил курс.
## Формат body
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
```text
[1] toBlockchainNameLen (uint8)
[N] toBlockchainName UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
[2] textLenBytes (uint16)
[M] text UTF-8
```
Где:
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
- `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока;
- `text` — опциональное пояснение пользователя к статусу.
## Правила
- Каждый `STATUS_ACTION` всегда является target-based сообщением.
- `STATUS_ACTION` не является line-based сообщением канала.
- Текст может быть пустым: основной смысл задаётся самим `subType`.
- Базовые статусы пользователь ставит сам за себя.
- `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок.
- Допустимые target-типы:
- `STATUS_DONE_ONCE` и `STATUS_LEARNED` только для `TEXT_EXERCISE`;
- `STATUS_SERVICE_PASSED` только для `TEXT_SERVICE`;
- `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_IN_STUDY`, `STATUS_ABANDONED`, `STATUS_COMPLETED` только для `TEXT_COURSE`.
## Что не поддерживается
- Отдельного `EDIT` для `STATUS_ACTION` нет.
- Если нужно изменить смысл статуса, пишется новое статусное событие.
+124
View File
@@ -0,0 +1,124 @@
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
## Область применения
Формат применяется к UTF-8 полю `text` в TEXT-блоках:
- `TEXT_POST`
- `TEXT_REPLY`
- `TEXT_EDIT_POST`
- `TEXT_EDIT_REPLY`
Бинарный формат `TextBody` не меняется. Вложения являются техническим префиксом внутри обычного текста.
## Общий вид
Один attach-блок:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Блок с превью:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Несколько вложений идут подряд в самом начале текста:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
## Поля
Обязательные поля:
- `v=1` - версия формата attach-блока;
- `name` - имя файла, закодированное через `encodeURIComponent`;
- `size` - размер файла в байтах;
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
Дополнительные поля для `v=2`:
- `previewAr` - короткий Arweave Transaction ID файла-превью;
- `previewSha256` - SHA-256 файла-превью в hex.
## Хранение файла
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
- имя файла;
- размер;
- SHA-256;
- Arweave `txId`.
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
## Создание вложения в UI
UI поддерживает три сценария:
- загрузить новый файл в Arweave выбранным Arweave-кошельком;
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла:
- основной видеофайл;
- отдельное изображение-превью.
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения.
Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением.
Если история очищена, уже созданные сообщения не меняются: в блокчейне остаются attach-блоки с `txId`.
## Отображение
Новый клиент при чтении сообщения:
1. читает валидные attach-блоки только из начала текста;
2. скрывает технические attach-строки;
3. отображает вложения над пользовательским текстом;
4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
5. определяет тип вложения по расширению имени файла:
- изображение показывает как ограниченное по размеру превью;
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
7. при перелистывании останавливает воспроизводящееся видео;
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
- если сообщение старше 20 минут: файл считается недоступным.
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
## Редактирование
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
## Совместимость и ограничения
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
+61
View File
@@ -0,0 +1,61 @@
# TEXT_CHANNEL_META: профиль канала
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала после создания.
## Блок
- `msg_type = 1`
- `subType = 90`
- `msgVersion = 1`
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
Первый снимок профиля публичного канала хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же текстовым форматом. Отдельный `TEXT_CHANNEL_META` при создании нового публичного канала UI не пишет.
## Текстовый формат
В начале текста могут идти технические теги, после них обычный текст описания:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
Поддерживаемые теги:
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
## Правила
- Каждый `TEXT_CHANNEL_META` задаёт полный снимок состояния профиля канала.
- Старые meta-блоки остаются в блокчейне как история, но текущее состояние берётся из последнего валидного meta-блока.
- Если `title` отсутствует, используется техническое имя канала.
- Если `avatar` отсутствует, аватар считается сброшенным.
- Если описание после тегов отсутствует, описание считается пустым.
- `meta_updated_at_ms` равен времени самого валидного meta-блока.
## Ограничения
- Длина `title` — максимум 50 Unicode code points.
- В `title` запрещены `<`, `>`, `;`, табуляция, перевод строки и `\0`.
- Длина описания — максимум 250 Unicode code points.
- `avatar.ar` — Arweave transaction id из 43 символов.
- `avatar.sha256` — 64 hex-символа.
- `avatar.size` — положительный размер файла в байтах.
Если meta-блок невалиден, сервер не применяет его целиком.
## UI
- При создании публичного канала UI записывает человекочитаемое имя, аватар и описание в `TECH_CREATE_CHANNEL.channelDescription`.
- При создании канала UI показывает системную карточку `Создан канал ...`.
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события.
## Замена старых команд
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
+103
View File
@@ -1,5 +1,87 @@
# История изменений документации блокчейна
## 2026-08-09 23:30:06 +0400
- Базовый коммит-ориентир: `ee185cf`.
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
- `10``STATUS_DONE_ONCE`;
- `20``STATUS_LEARNED`;
- `30``STATUS_SERVICE_PASSED`;
- `100``STATUS_CONFIRMED`;
- `110/120/130/140/150` — курсные статусы `INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED`.
- Зафиксированы допустимые связи `STATUS_ACTION -> target`:
- упражнение: `DONE_ONCE`, `LEARNED`;
- услуга: `SERVICE_PASSED`;
- курс: `INTERESTED`, `STARTED`, `IN_STUDY`, `ABANDONED`, `COMPLETED`.
- Добавлен серверный read API `GetPersonalDiary` для виртуальной ленты личного дневника из `STATUS_ACTION`.
## 2026-08-09 19:40:00 +0400
- Базовый коммит-ориентир: `3552e05`.
- Уточнено серверное чтение каналов и тредов для `TEXT_RATING`:
- `GetChannelMessages` и `GetMessageThread` теперь отдают отдельное поле `ratingsCount`;
- `GetMessageThread` включает `TEXT_RATING` в общее дерево потомков вместе с `TEXT_REPLY`;
- в `docs/API/06_Channels_Read_API.md` зафиксировано, что потомки треда возвращаются вперемешку по времени создания.
## 2026-08-09 18:55:16 +0400
- Базовый коммит-ориентир: `43f54c9`.
- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов:
- `TEXT_RATING` добавлен как `subType=30` и трактуется как target-based отзыв на конкретный блок;
- `TEXT_REPOST` перенесён на `subType=50` и оставлен как отложенная заготовка;
- `TEXT_CHANNEL_META` перенесён на `subType=90`;
- добавлены line-based `TEXT_ENTRYPOINT (100)`, `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)`, `TEXT_COURSE (130)`.
- Добавлен новый верхнеуровневый тип `STATUS_ACTION (type=5)` с подтипами `10/20/30/40/50/60/70/80`.
- `CHANNEL_MEMBERSHIP` в текущую реализацию не включён и ведётся отдельно как отложенная тема.
## 2026-08-04 12:00:00 +0400
- Базовый коммит-ориентир: `391b18a`.
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением.
- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью.
## 2026-07-31 17:22:07 +0400
- Базовый коммит-ориентир: `05bd9f5`.
- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён.
## 2026-07-31 17:13:26 +0400
- Базовый коммит-ориентир: `60ab476`.
- Уточнён UI журнала загрузок Arweave: статус доступности перенесён в начало второй строки плитки, верхняя панель закреплена как fixed-полоса вне прокрутки, в меню добавлен пункт `Добавить файл`, меню закрывается при клике вне него; формат attach-блока не изменён.
## 2026-07-31 17:06:55 +0400
- Базовый коммит-ориентир: `bcae285`.
- Уточнён UI журнала загрузок Arweave: плитки принудительно не растягиваются по высоте, а контейнер журнала запрещает горизонтальную прокрутку; формат attach-блока не изменён.
## 2026-07-31 16:58:00 +0400
- Базовый коммит-ориентир: `cd67176`.
- Уточнён UI журнала загрузок Arweave: экран называется `Загрузка файлов`, верхняя панель стала компактной и закреплённой сверху, плитки журнала тёмные и уменьшены по высоте; формат attach-блока не изменён.
## 2026-07-31 13:48:57 +0400
- Базовый коммит-ориентир: `98eba54`.
- Уточнено UI-поведение журнала загрузок Arweave: экран называется `Загрузить файлы в блокчейн`, использует компактные плитки и локальный флаг `Не добавлен в SHiNE` для файлов, которые загружены заранее, но ещё не отправлены во вложении сообщения.
- Зафиксировано, что флаг хранится только в клиентском sessionStorage и снимается после успешной отправки сообщения с выбранным вложением; формат attach-блока не изменён.
## 2026-07-31 13:12:24 +0400
- Базовый коммит-ориентир: `c530627`.
- Для UI-вложений добавлен отдельный экран журнала загрузок Arweave в настройках: загрузка файла без создания сообщения, плитки истории с `txId`, размером, датой и статусом доступности через gateway.
- Уточнено, что журнал хранится только в `sessionStorage` текущего браузера и не влияет на уже записанные attach-блоки в блокчейне.
## 2026-07-31 12:47:41 +0400
- Базовый коммит-ориентир: `f961947`.
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
## 2026-07-30 11:12:47 +0400
- Базовый коммит-ориентир: `e9e6628`.
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
- В `docs/Blockchain/README.md` добавлена ссылка на документ вложений.
- В UI порядок отображения сообщения с вложениями закреплён как: вложения сверху, пользовательский текст снизу.
## 2026-07-30 10:31:26 +0400
- Базовый коммит-ориентир: `c7684d6`.
- Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксирован клиентский формат вложений `SHiNE:attach v=1`.
- Бинарный `TextBody` не изменён: вложения записываются техническими строками в начале UTF-8 `text`.
- Вложение хранит `name`, `size`, `sha256` и короткий Arweave `txId` в поле `ar`; имя файла кодируется через `encodeURIComponent`.
- В `docs/Blockchain/11_TEXT_Blocks.md` добавлены правила совместимости, отображения и допустимости пустого пользовательского текста при наличии вложений.
## 2026-07-14 20:43:43 +0400
- Базовый коммит-ориентир: `118be41`.
- В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта:
@@ -68,6 +150,27 @@
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
## 2026-08-01 01:45:29 +0400
- Базовый коммит-ориентир: `24afcba`.
- Добавлен новый `TEXT`-подтип `TEXT_CHANNEL_META (subType=70)` для скрытого технического снимка профиля канала.
- Зафиксирован формат тегов `<SHiNE:title;v=1;...>` и `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`; описание хранится хвостовым UTF-8 текстом.
- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром.
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`.
## 2026-08-01 02:18:00 +0400
- Базовый коммит-ориентир: `8125d1a`.
- Увеличен лимит `CreateChannelBody.channelDescription` до `2048` UTF-8 байт.
- Для публичных каналов начальный профиль канала теперь хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же meta-форматом, что `TEXT_CHANNEL_META`.
- UI создания публичного канала больше не пишет отдельный `TEXT_CHANNEL_META` сразу после создания; отдельный meta-блок используется только для последующих изменений профиля.
- Bootstrap `channel_names_state` при старте сервера теперь применяет последующие `TEXT_CHANNEL_META` поверх начального профиля из `TECH_CREATE_CHANNEL`.
## 2026-07-31 17:40:31 +0400
- Базовый коммит-ориентир: `d640dd6`.
- Формат блокчейна не менялся.
- В UI каналов добавлен технический просмотр данных сообщения из уже полученного ответа сервера: цепочка автора, номер записи, хэш, тип, время, текст, public key/подпись при наличии и сырой JSON-блок.
- Удалённые сообщения в UI теперь отображаются компактной строкой с переходом в историю версий.
## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
+16 -10
View File
@@ -3,25 +3,31 @@
Этот каталог описывает только текущий рабочий формат протокола для MVP.
## Основные документы
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
Системные блоки (`msg_type=0`).
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
Статусные действия пользователя (`msg_type=5`).
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
Скрытый `TEXT_CHANNEL_META` для профиля канала.
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`.
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
12. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
10. [CHANGELOG.md](./CHANGELOG.md)
13. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Смежная документация
-2
View File
@@ -155,8 +155,6 @@
При переносе нужно отдельно учитывать:
- заголовок и стрелку назад;
- поля логина и пароля;
- переключатель режима 12 слов;
- сетку слов;
- строку статуса длины пароля;
- строку статуса проверки логина;
- кнопку проверки логина;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

+17 -2
View File
@@ -2,14 +2,17 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<base href="/" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260715214500';
window.__SHINE_BUILD_HASH__ = '20260806223040';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
@@ -26,8 +29,20 @@ window.__SHINE_BUILD_HASH__ = '20260715214500';
</script>
</head>
<body>
<div id="initial-splash" class="initial-splash" aria-label="Сияние">
<div class="initial-splash__logo-wrap">
<img
class="initial-splash__logo"
src="./img/shine-logo-transparent-final.png"
alt="Логотип Сияние"
/>
</div>
<div class="initial-splash__brand">Сияние</div>
</div>
<div class="app-shell">
<div id="topbar-slot" class="topbar-slot" hidden></div>
<main id="app-screen" class="screen-content"></main>
<div id="composer-slot" class="composer-slot" hidden></div>
<div id="toolbar-slot" class="toolbar-slot"></div>
</div>
<div id="modal-root"></div>
+162 -4
View File
@@ -10,6 +10,7 @@ import {
handleIncomingCallInvite,
handleIncomingCallPush,
handleIncomingCallSignal,
handleIncomingCallSignalViaSendSignal,
handleStopCallPush,
setCallDebugReporter,
startDebugConnectionAsInitiator,
@@ -59,6 +60,7 @@ import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
@@ -120,6 +122,7 @@ const routes = {
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
@@ -148,8 +151,11 @@ const routes = {
};
const screenEl = document.getElementById('app-screen');
const topbarEl = document.getElementById('topbar-slot');
const composerEl = document.getElementById('composer-slot');
const toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell');
const initialSplashEl = document.getElementById('initial-splash');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
@@ -175,6 +181,9 @@ let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = false;
let orientationLockInFlight = false;
let currentChromeCleanup = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
@@ -198,6 +207,90 @@ initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
function setShellMetricVar(name, valuePx) {
if (!appShellEl) return;
appShellEl.style.setProperty(name, `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
}
function setKeyboardOffsetPx(valuePx = 0) {
setShellMetricVar('--keyboard-offset', valuePx);
}
function attachSlotHeightObserver(slotEl, cssVarName) {
if (!slotEl || typeof ResizeObserver !== 'function') return null;
const sync = () => {
const visible = !slotEl.hidden && slotEl.childElementCount > 0;
setShellMetricVar(cssVarName, visible ? slotEl.offsetHeight : 0);
};
const observer = new ResizeObserver(sync);
observer.observe(slotEl);
sync();
return { observer, sync };
}
const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height');
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
function clearSlot(slotEl, cssVarName) {
if (!slotEl) return;
slotEl.innerHTML = '';
slotEl.hidden = true;
setShellMetricVar(cssVarName, 0);
}
function mountSlot(slotEl, cssVarName, node) {
if (!slotEl) return;
slotEl.innerHTML = '';
if (node instanceof Node) {
slotEl.append(node);
slotEl.hidden = false;
} else {
slotEl.hidden = true;
}
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
}
function createChromeController(showAppChrome) {
let topbarNode = null;
let composerNode = null;
let disposed = false;
const apply = () => {
if (disposed) return;
if (!showAppChrome) {
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
return;
}
mountSlot(topbarEl, '--topbar-height', topbarNode);
mountSlot(composerEl, '--composer-height', composerNode);
topbarHeightObserver?.sync?.();
composerHeightObserver?.sync?.();
};
return {
setTopbar(node = null) {
topbarNode = node instanceof Node ? node : null;
apply();
},
setComposer(node = null) {
composerNode = node instanceof Node ? node : null;
apply();
},
clear() {
topbarNode = null;
composerNode = null;
apply();
},
dispose() {
disposed = true;
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
},
};
}
async function unlockHiddenDmAudio() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -253,6 +346,23 @@ async function playDmSignal({ extended = false } = {}) {
}
}
async function tryLockPortraitOrientation() {
if (orientationLockInFlight) return false;
const orientationApi = window.screen?.orientation;
if (!orientationApi?.lock) return false;
orientationLockInFlight = true;
try {
await orientationApi.lock('portrait');
return true;
} catch {
return false;
} finally {
orientationLockInFlight = false;
}
}
async function notifyHiddenIncomingMessage(fromLogin, text) {
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
const title = `Сообщение от ${fromLogin}`;
@@ -329,6 +439,11 @@ function refreshConnectionUi() {
const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return;
if (!initialConnectionCompleted) {
bannerEl.hidden = true;
return;
}
if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true;
stopConnectionCountdown();
@@ -358,6 +473,21 @@ function refreshConnectionUi() {
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
}
function finishInitialConnectionSplash() {
if (initialConnectionCompleted) return;
initialConnectionCompleted = true;
if (!initialSplashEl) {
refreshConnectionUi();
return;
}
initialSplashEl.classList.add('is-leaving');
const removeSplash = () => initialSplashEl.remove();
initialSplashEl.addEventListener('transitionend', removeSplash, { once: true });
window.setTimeout(removeSplash, 700);
refreshConnectionUi();
}
function startConnectionCountdown() {
if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => {
@@ -465,6 +595,9 @@ function setConnectionStatus(nextState, text = '') {
if (!state) return;
connectionState = state;
connectionStatusText = String(text || '').trim();
if (state === 'connected') {
finishInitialConnectionSplash();
}
if (state === 'disconnected') {
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
@@ -862,7 +995,14 @@ function renderPageFailureFallback(pageId, error) {
screenEl.append(wrap);
screenEl.classList.toggle('no-app-chrome', false);
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
toolbarEl.innerHTML = '';
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
}
@@ -886,10 +1026,17 @@ function renderApp() {
currentCleanup();
currentCleanup = null;
}
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
try {
screenEl.innerHTML = '';
const screen = page.render({ route, navigate });
const showAppChrome = page.pageMeta?.showAppChrome !== false;
const chrome = createChromeController(showAppChrome);
currentChromeCleanup = () => chrome.dispose();
const screen = page.render({ route, navigate, chrome });
if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node');
}
@@ -897,7 +1044,6 @@ function renderApp() {
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
@@ -905,6 +1051,7 @@ function renderApp() {
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
} catch (error) {
console.error('[renderApp] controlled fallback', error);
@@ -922,6 +1069,7 @@ function refreshToolbarOnly() {
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
}
@@ -1000,6 +1148,7 @@ async function ensureSessionRuntimeStarted() {
async function init() {
consumeCallPushActionFromUrlIfAny();
void tryLockPortraitOrientation();
if (state.session.isLocalDemo) {
setConnectionStatus('connected');
@@ -1183,13 +1332,14 @@ async function init() {
if (added && isIncomingForCurrent && !payload.backlog) {
const parsedText = parseDmTechBlocks(text);
const displayText = String(parsedText.displayText || '');
const suppressHiddenNotification = parsedText.callSummary?.status === 'completed';
if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') {
} else if (Notification.permission === 'granted' && !suppressHiddenNotification) {
try {
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
} catch {}
} else {
} else if (!suppressHiddenNotification) {
void playDmSignal({ extended: true });
}
}
@@ -1286,6 +1436,10 @@ async function init() {
try { await handleIncomingCallSignal(evt); } catch {}
});
authService.onEvent('IncomingSignal', async (evt) => {
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
});
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
try {
const p = evt?.payload || {};
@@ -1378,8 +1532,12 @@ async function init() {
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
void tryLockPortraitOrientation();
void checkConnectionHealth();
});
window.addEventListener('orientationchange', () => {
void tryLockPortraitOrientation();
});
}
init();
File diff suppressed because it is too large Load Diff
+28 -608
View File
@@ -1,56 +1,7 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
prepareAvatarImageFile,
sha256HexFromArrayBuffer,
uploadArweaveFile,
validateArweaveTxId,
validateSha256Hex,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function formatAr(ar) {
const n = Number(ar);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
}
function clearJwk(ctx) {
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
Object.keys(ctx.jwk).forEach((key) => {
ctx.jwk[key] = '';
});
ctx.jwk = null;
}
function setNodeText(node, text) {
if (node) node.textContent = String(text || '');
}
export function openAvatarWizard({
export async function openAvatarWizard({
login,
storagePwd,
gateway,
@@ -58,568 +9,37 @@ export function openAvatarWizard({
onAvatarSaved,
onStatus,
} = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || '').trim();
if (!cleanLogin || !cleanStoragePwd) {
return Promise.reject(new Error('Нет активной сессии.'));
throw new Error('Нет активной сессии.');
}
let closed = false;
let lastPreviewUrl = '';
let walletCtx = null;
let balanceInfo = null;
let optimized = null;
let priceInfo = null;
let uploadedTxId = '';
let uploadedSha256Hex = '';
let uploadedInfoText = '';
let freeQuotaInfo = null;
function revokePreviewUrl() {
if (!lastPreviewUrl) return;
URL.revokeObjectURL(lastPreviewUrl);
lastPreviewUrl = '';
}
function close(result = false, resolve) {
if (closed) return;
closed = true;
revokePreviewUrl();
clearJwk(walletCtx);
walletCtx = null;
root.innerHTML = '';
resolve(result);
}
async function ensurePreviewImage(url, imageEl) {
return new Promise((resolve, reject) => {
const img = imageEl;
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
img.src = url;
});
}
return new Promise((resolve) => {
const showStepChoice = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
<div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
};
const showStepExistingInput = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="next">Далее</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const inputEl = root.querySelector('#avatar-existing-txid');
const errorEl = root.querySelector('[data-error="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
const txId = String(inputEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
setNodeText(errorEl, '');
void showStepExistingPreview(txId);
});
window.setTimeout(() => inputEl?.focus(), 0);
};
const showStepExistingPreview = async (txId) => {
if (closed) return;
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
let existingSha256Hex = '';
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Предпросмотр аватара</h3>
<div class="avatar-preview-circle avatar-wizard-preview">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const imageEl = root.querySelector('[data-preview-image="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const saveBtn = root.querySelector('[data-action="save"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
try {
await ensurePreviewImage(previewUrl, imageEl);
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
}
saveBtn?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
};
const showStepZeroBalance = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Недостаточно средств</h3>
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
<div class="avatar-wizard-actions">
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
close(false, resolve);
if (typeof navigate === 'function') navigate('wallet-view');
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUploadForm = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
let selectedFile = null;
optimized = null;
priceInfo = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
priceInfo = await getArweaveUploadPrice({
gateway: cleanGateway,
byteLength: optimized.file.size,
});
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
`;
if (!hasFunds) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} catch (error) {
const message = String(error?.message || '');
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
setNodeText(errorEl, message);
} else if (message.includes('цену загрузки')) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
} else {
setNodeText(errorEl, 'Не удалось подготовить изображение.');
}
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
return;
}
uploadBtn.disabled = true;
try {
uploadedInfoText = '';
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
],
});
uploadedTxId = String(uploaded.id || '').trim();
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
if (!uploadedTxId) {
throw new Error('Пустой Transaction ID');
}
if (!validateSha256Hex(uploadedSha256Hex)) {
throw new Error('Некорректный SHA256');
}
showStepUploaded();
} catch {
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
uploadBtn.disabled = false;
}
});
};
const showStepFreeLimitExhausted = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Лимит исчерпан</h3>
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepFreeUploadForm = () => {
if (closed) return;
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
const limit = Number(freeQuotaInfo?.limit || 3);
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Залить аватар бесплатно</h3>
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-free-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
optimized = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
const selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
if (Number(optimized?.file?.size || 0) > maxBytes) {
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
}
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
`;
uploadBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
uploadBtn.disabled = true;
try {
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
const uploaded = await authService.uploadTestFreeAvatar({
contentType: optimized.contentType,
fileBytesBase64: bytesToBase64(fileBytes),
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
});
uploadedTxId = String(uploaded.txId || '').trim();
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
if (!uploadedTxId) {
throw new Error('Сервер не вернул Transaction ID.');
}
showStepUploaded();
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
uploadBtn.disabled = false;
}
});
};
const showStepFreeUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
} catch (error) {
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
return;
}
if (!freeQuotaInfo?.enabled) {
setNodeText(loadingEl, '');
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
return;
}
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
showStepFreeLimitExhausted();
return;
}
showStepFreeUploadForm();
};
const showStepUploaded = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Файл загружен в Arweave</h3>
<p class="meta-muted">Transaction ID:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
</div>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
`;
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(uploadedTxId);
setNodeText(errorEl, '');
} catch {
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
}
});
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка загрузки</h3>
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
walletCtx = await getArweaveWalletFromStoredClientKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
onStatus: (message) => {
setNodeText(loadingEl, message);
if (typeof onStatus === 'function') onStatus(message);
},
});
balanceInfo = await getArweaveBalance({
gateway: cleanGateway,
address: walletCtx.address,
});
} catch (error) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
return;
}
if (BigInt(balanceInfo.winston) <= 0n) {
showStepZeroBalance();
return;
}
showStepUploadForm();
};
showStepChoice();
const item = await openArweaveAttachmentManager({
login: cleanLogin,
storagePwd: cleanStoragePwd,
gateway: cleanGateway,
navigate,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (!item?.ar || !item?.sha256) return false;
if (typeof onStatus === 'function') {
onStatus('Сохраняем аватар в профиль...');
}
await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256);
if (typeof onAvatarSaved === 'function') {
await onAvatarSaved(item);
}
if (typeof onStatus === 'function') {
onStatus('');
}
return true;
}
-1
View File
@@ -78,7 +78,6 @@ export function renderToolbar(currentPageId, navigate) {
<span class="toolbar-label-wrap">
<span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
<span class="toolbar-connection-text">connected</span>
<span class="toolbar-connection-dot" aria-hidden="true"></span>
</span>
</span>
+19 -4
View File
@@ -7,6 +7,7 @@ import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
const MAX_ACCESS_SERVERS = 2;
function escapeHtml(value) {
return String(value || '')
@@ -39,8 +40,9 @@ function uniqueLogins(values) {
if (!login || seen.has(login)) return;
seen.add(login);
result.push(login);
if (result.length >= MAX_ACCESS_SERVERS) return;
});
return result;
return result.slice(0, MAX_ACCESS_SERVERS);
}
function shortenSignature(value) {
@@ -309,7 +311,7 @@ export function render({ navigate }) {
addTitle.textContent = 'Добавить сервер доступа';
const addHint = document.createElement('p');
addHint.className = 'meta-muted';
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
const addInput = document.createElement('input');
addInput.className = 'input';
addInput.type = 'text';
@@ -329,7 +331,9 @@ export function render({ navigate }) {
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
const refreshAddButton = () => {
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
addButton.disabled = operationBusy
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|| (!selectedCandidate && !normalizeLogin(addInput.value));
};
const setOperationBusy = (busy) => {
@@ -348,7 +352,9 @@ export function render({ navigate }) {
addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else {
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
: 'Для изменения списка понадобится подпись root key.';
}
};
@@ -457,12 +463,17 @@ export function render({ navigate }) {
}
currentAccessServers = rows;
renderServerList();
refreshAddButton();
listStatus.textContent = rows.length
? `Найдено серверов доступа: ${rows.length}`
: 'В PDA пользователя пока нет серверов доступа.';
if (rows.length >= MAX_ACCESS_SERVERS) {
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
}
} catch (error) {
currentAccessServers = [];
renderServerList();
refreshAddButton();
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
}
};
@@ -654,6 +665,10 @@ export function render({ navigate }) {
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
return;
}
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
return;
}
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
+208 -25
View File
@@ -2,16 +2,23 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDescription,
normalizeChannelDisplayName,
validateChannelDisplayName,
} from '../services/channel-name-rules.js';
import {
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) {
try {
@@ -21,13 +28,55 @@ function persistCreateSuccessFlash(message) {
}
}
function validateDescription(value) {
const normalized = normalizeChannelDescription(value);
const bytes = new TextEncoder().encode(normalized).length;
if (bytes > 200) {
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
function normalizeMetaText(value, max, label) {
const normalized = String(value || '').trim();
const length = Array.from(normalized).length;
if (length > max) {
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
}
return { ok: true, normalized, bytes, error: '' };
return { ok: true, normalized, length, error: '' };
}
function renderAvatarPreview(slot, avatar, title) {
if (!slot) return;
slot.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap);
}
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
const route = makeShineChannelRoute({ ownerBlockchainName, channelName });
if (!route) return '';
try {
return new URL(`/${route}`, window.location.origin).toString();
} catch {
return `${window.location.origin}/${route}`;
}
}
function sanitizeChannelLoginInput(value) {
const source = String(value || '');
let result = '';
for (const ch of source) {
if (/[A-Za-z0-9_-]/.test(ch)) result += ch;
}
return result.slice(0, 32);
}
function shortAvatarBlockchainAddress(value) {
const raw = String(value || '').trim();
if (raw.length <= 24) return raw;
return raw.slice(-24);
}
export function render({ navigate }) {
@@ -36,7 +85,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Создать канал',
title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
@@ -44,19 +93,31 @@ export function render({ navigate }) {
const form = document.createElement('form');
form.className = 'card stack';
form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Разрешены только латинские буквы, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.</p>
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
<div class="channel-create-avatar-head">
<div id="channel-avatar-preview"></div>
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар"></button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
</div>
<label for="channel-name">Название канала</label>
<label for="channel-name">Технический логин канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
<div class="meta-muted channel-link-preview" id="channel-link-preview">&nbsp;</div>
<div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-title">Как канал будет виден пользователям</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
<div id="channel-description-error" class="meta-muted inline-error"></div>
<div id="channel-create-error" class="meta-muted inline-error"></div>
@@ -67,8 +128,16 @@ export function render({ navigate }) {
`;
const nameEl = form.querySelector('#channel-name');
const titleEl = form.querySelector('#channel-title');
const descriptionEl = form.querySelector('#channel-description');
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
const avatarStatusEl = form.querySelector('#channel-avatar-status');
const avatarBtn = form.querySelector('#channel-avatar-btn');
const avatarRemoveBtn = form.querySelector('#channel-avatar-remove');
const linkPreviewEl = form.querySelector('#channel-link-preview');
const nameErrorEl = form.querySelector('#channel-name-error');
const titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter');
const descriptionErrorEl = form.querySelector('#channel-description-error');
const descriptionCounterEl = form.querySelector('#channel-description-counter');
const errorEl = form.querySelector('#channel-create-error');
@@ -76,38 +145,135 @@ export function render({ navigate }) {
const cancelEl = form.querySelector('#cancel-create-channel');
let submitInFlight = false;
let selectedAvatar = null;
let ownerBlockchainName = '';
let avatarAvailabilityLabel = '';
let avatarAvailabilityTxId = '';
const renderAvatarStatus = (nameValue, titleValue) => {
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleValue || nameValue);
avatarRemoveBtn.hidden = !selectedAvatar?.ar;
if (!selectedAvatar?.ar) {
avatarStatusEl.innerHTML = '<p class="channel-avatar-status-text">Аватар канала не установлен.</p>';
return;
}
const txId = String(selectedAvatar.ar || '').trim();
const shortTxId = shortAvatarBlockchainAddress(txId);
const isPending = avatarAvailabilityLabel === 'pending' || avatarAvailabilityTxId !== txId;
avatarStatusEl.innerHTML = `
<p class="channel-avatar-status-text">Аватар канала</p>
<p class="channel-avatar-status-line">Адрес аватара в блокчейне Arweave <span class="channel-avatar-txid">${shortTxId}</span><span class="channel-avatar-status-pending">${isPending ? ', ещё не обновился в блокчейне.' : ''}</span></p>
`;
};
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.innerHTML = '&nbsp;';
return;
}
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
});
linkPreviewEl.innerHTML = `Прямая ссылка: <a href="${directUrl}" target="_blank" rel="noopener noreferrer">${directUrl}</a>`;
};
const refreshAvatarAvailability = async () => {
const txId = String(selectedAvatar?.ar || '').trim();
avatarAvailabilityTxId = txId;
avatarAvailabilityLabel = txId ? 'pending' : '';
renderAvatarStatus(nameEl.value, titleEl.value);
if (!txId) return;
try {
const availability = await getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: selectedAvatar,
});
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = String(availability?.status || '').trim().toLowerCase();
} catch {
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = 'pending';
}
renderAvatarStatus(nameEl.value, titleEl.value);
};
const setBusy = (busy) => {
submitInFlight = !!busy;
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight;
titleEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight;
avatarBtn.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
};
const updateValidation = () => {
const nameCheck = validateChannelDisplayName(nameEl.value);
const descriptionCheck = validateDescription(descriptionEl.value);
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
nameErrorEl.textContent = '';
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
const descLength = Number(descriptionCheck.bytes || 0);
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
const ok = nameCheck.ok && descriptionCheck.ok;
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return {
ok,
name: nameCheck.normalized,
title: titleCheck.normalized,
description: descriptionCheck.normalized,
};
};
nameEl.addEventListener('input', updateValidation);
nameEl.addEventListener('input', () => {
const raw = String(nameEl.value || '');
const sanitized = sanitizeChannelLoginInput(raw);
if (raw !== sanitized) {
nameEl.value = sanitized;
window.alert(CHANNEL_LOGIN_HINT);
}
updateValidation();
});
titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => {
try {
const nextAvatar = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (nextAvatar?.ar) {
selectedAvatar = nextAvatar;
void refreshAvatarAvailability();
}
updateValidation();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
}
});
avatarRemoveBtn.addEventListener('click', () => {
if (!selectedAvatar?.ar) return;
const confirmed = window.confirm('Убрать аватар и сделать канал без аватара?');
if (!confirmed) return;
selectedAvatar = null;
avatarAvailabilityLabel = '';
avatarAvailabilityTxId = '';
updateValidation();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -121,6 +287,11 @@ export function render({ navigate }) {
}
const check = updateValidation();
if (check.name && /^[0-9]+$/.test(check.name)) {
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
nameEl.focus();
return;
}
if (!check.ok) return;
setBusy(true);
@@ -131,10 +302,13 @@ export function render({ navigate }) {
login,
storagePwd,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description),
channelDescription: check.description,
channelProfileTitle: check.title,
channelAvatar: selectedAvatar,
channelType: CHANNEL_TYPE_PUBLIC,
channelTypeVersion: 1,
});
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
navigate('channels-list');
@@ -150,5 +324,14 @@ export function render({ navigate }) {
screen.append(form);
nameEl.focus();
updateValidation();
void authService.getUser(String(state.session.login || '').trim())
.then((user) => {
ownerBlockchainName = String(user?.blockchainName || '').trim();
renderChannelLinkPreview(nameEl.value);
})
.catch(() => {
ownerBlockchainName = '';
renderChannelLinkPreview(nameEl.value);
});
return screen;
}
+173
View File
@@ -0,0 +1,173 @@
import {
clearArweaveAttachmentHistory,
formatArweaveHistoryTime,
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
function closeUploadsMenu(controls) {
const menu = controls?.querySelector?.('[data-menu="true"]');
if (menu) menu.hidden = true;
}
function setStatusBadge(node, status, label) {
if (!node) return;
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
node.textContent = label || 'Проверяем...';
}
function renderTile(item, index) {
const tile = document.createElement('article');
tile.className = `ar-attachment-history-tile ar-attachment-history-tile--page${item?.preview?.ar ? ' ar-attachment-history-tile--with-preview' : ''}`;
const name = document.createElement('strong');
name.className = 'ar-attachment-history-name';
name.textContent = item.name;
const status = document.createElement('span');
status.className = 'ar-attachment-status ar-attachment-status--pending';
status.dataset.statusIndex = String(index);
status.textContent = 'Проверяем...';
const meta = document.createElement('div');
meta.className = 'ar-attachment-history-meta-row';
const size = document.createElement('span');
size.textContent = formatBytes(item.size);
const time = document.createElement('span');
time.textContent = formatArweaveHistoryTime(item.uploadedAtMs);
meta.append(status, size, time);
if (item?.preview?.ar) {
const previewFlag = document.createElement('span');
previewFlag.className = 'ar-attachment-placement-flag';
previewFlag.textContent = 'С превью';
meta.append(previewFlag);
}
if (item.pendingPlacement) {
const flag = document.createElement('span');
flag.className = 'ar-attachment-placement-flag';
flag.textContent = 'Не добавлен в SHiNE';
meta.append(flag);
}
const txId = document.createElement('div');
txId.className = 'mono-cell ar-attachment-history-txid';
txId.textContent = item.ar;
tile.append(name, meta, txId);
return tile;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack arweave-uploads-screen';
const controls = document.createElement('div');
controls.className = 'arweave-uploads-toolbar';
controls.innerHTML = `
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад"></button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
<button class="text-btn" type="button" data-action="help">Справка</button>
</div>
`;
const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error';
const list = document.createElement('div');
list.className = 'ar-attachment-history-tiles arweave-uploads-list';
const uploadFile = async () => {
statusLine.textContent = '';
closeUploadsMenu(controls);
try {
await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: true,
});
renderHistory();
} catch (error) {
statusLine.textContent = toUserMessage(error, 'Не удалось загрузить файл.');
}
};
function renderHistory() {
const rows = readArweaveAttachmentHistory(state.session.login).slice().reverse();
list.innerHTML = '';
statusLine.textContent = '';
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
list.append(empty);
return;
}
rows.forEach((item, index) => {
list.append(renderTile(item, index));
const statusEl = list.querySelector(`[data-status-index="${index}"]`);
void getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: item,
})
.then((result) => setStatusBadge(statusEl, result.status, result.label))
.catch(() => setStatusBadge(statusEl, 'unavailable', 'Недоступен'));
});
window.setTimeout(() => {
list.scrollTop = list.scrollHeight;
}, 0);
}
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
event.stopPropagation();
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = !menu.hidden;
});
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
});
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
);
});
screen.addEventListener('click', (event) => {
if (controls.contains(event.target)) return;
closeUploadsMenu(controls);
});
screen.append(controls, statusLine, list);
renderHistory();
return screen;
}
+335 -31
View File
@@ -12,10 +12,24 @@ import {
} from '../services/channels-ux.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const pendingReactionActions = new Set();
const pendingThreadScroll = new Map();
@@ -201,6 +215,46 @@ function resolveChannelDisplayName(channelSelector) {
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
}
function resolveChannelHeadingFromNode(node) {
const info = node?.channelInfo;
const ownerBch = String(info?.ownerBlockchainName || '').trim();
const rootBlockNumber = Number(info?.channelRoot?.blockNumber);
if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return '';
if (rootBlockNumber === 0) return 'История пользователя';
const label = resolveChannelDisplayName({
ownerBlockchainName: ownerBch,
channelRootBlockNumber: rootBlockNumber,
channelRootBlockHash: '0',
});
const slashIndex = label.indexOf('/');
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim();
if (!channelName) return 'Сообщение в канале';
return `Сообщение в канале ${channelName}`;
}
function getChannelMessageTypeMeta(msgSubType) {
switch (Number(msgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE:
return { label: 'Упражнение', actionable: true };
case MSG_SUBTYPE_TEXT_SERVICE:
return { label: 'Услуга', actionable: true };
case MSG_SUBTYPE_TEXT_COURSE:
return { label: 'Курс', actionable: true };
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
return { label: 'Оглавление' };
default:
return null;
}
}
function isEditableAsChannelPostSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE;
}
function extractChannelContextFromThreadPayload(payload) {
const focusInfo = payload?.focus?.channelInfo;
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
@@ -322,22 +376,137 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
function setActionTitle(button, label) {
if (!button) return;
button.title = label;
button.setAttribute('aria-label', label);
const labelEl = button.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = label;
}
async function copyTextToClipboard(text) {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
authorLogin,
authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '',
blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
blockHash: target?.blockHash || source?.messageRef?.blockHash || '',
localNumber,
msgSubType: msgSubType ?? source.msgSubType ?? '',
createdAtMs: timestampMs || source.createdAtMs || '',
text: String(text || ''),
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
raw,
};
}
function openBlockchainDetailsModal(details) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
<label class="field-label" for="thread-blockchain-details-text">Текст записи</label>
<textarea class="input" id="thread-blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
<pre class="blockchain-raw-block" id="thread-blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-blockchain-details-copy" type="button">Скопировать</button>
<button class="secondary-btn" id="thread-blockchain-details-raw" type="button">Показать сырой блок</button>
</div>
<button class="secondary-btn" id="thread-blockchain-details-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
showToast('Данные блокчейна скопированы');
});
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
const rawEl = root.querySelector('#thread-blockchain-raw-block');
if (!rawEl) return;
rawEl.hidden = !rawEl.hidden;
});
}
function resolveNodeText(node) {
return firstNonEmptyText(
latestVersionText(node?.versions),
node?.text,
node?.message,
node?.body,
latestVersionText(node?.versions),
);
}
function openReplyModal({ onSubmit, navigate }) {
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
const emptyError = isRating
? 'Введите текст оценки или добавьте вложение.'
: 'Введите текст ответа или добавьте вложение.';
const submitError = isRating
? 'Не удалось отправить оценку.'
: 'Не удалось отправить ответ.';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<h3 class="modal-title">${title}</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
@@ -348,14 +517,17 @@ function openReplyModal({ onSubmit, navigate }) {
`;
const textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -368,8 +540,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
if (!text && attachments.length === 0) {
errorEl.textContent = emptyError;
return;
}
@@ -377,11 +549,32 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(text);
await onSubmit(composeMessageWithAttachments(text, attachments));
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
errorEl.textContent = toUserMessage(error, submitError);
}
});
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -504,7 +697,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -531,7 +724,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
@@ -557,7 +750,6 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function renderNodeCard(node, heading, handlers, localNumber) {
const card = document.createElement('article');
card.className = 'card stack thread-node-card channel-message-card';
card.classList.add('is-counters-visible');
const author = node?.authorLogin || 'автор';
const versions = Array.isArray(node?.versions) ? node.versions : [];
@@ -565,10 +757,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const msgSubType = Number(node?.msgSubType || 0);
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
const parsedText = parseMessageAttachments(text);
if (isRating) card.classList.add('is-rating');
card.classList.add('is-counters-visible');
const headingText = String(heading || '').trim();
if (headingText) {
@@ -581,6 +778,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const avatar = createThreadAvatar(author);
@@ -588,13 +787,16 @@ function renderNodeCard(node, heading, handlers, localNumber) {
authorBlock.className = 'channel-message-author';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const titleMain = document.createElement('div');
titleMain.className = 'author-line-main';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = author;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${localNumber}`;
title.append(loginEl, numberEl);
titleMain.append(loginEl, numberEl);
title.append(titleMain);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
@@ -616,13 +818,63 @@ function renderNodeCard(node, heading, handlers, localNumber) {
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
const typeMeta = getChannelMessageTypeMeta(node?.msgSubType);
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
handlers.onStatusAction(node);
});
} else {
typeButton.classList.add('is-static');
typeButton.disabled = true;
}
headRow.append(typeButton);
}
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
card.append(authorTile, body);
if (isDeletedMessage) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${author}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
event.stopPropagation();
openMessageHistoryModal({
title: `История #${localNumber}`,
versions,
});
});
card.append(deleted);
return card;
} else {
card.append(headRow);
if (parsedText.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: node?.createdAtMs,
}));
}
if (isRating) {
const ratingBadge = document.createElement('span');
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
ratingBadge.textContent = 'Оценка';
card.append(ratingBadge);
}
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = parsedText.text;
card.append(body);
}
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
@@ -649,6 +901,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -660,6 +913,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
setActionTitle(likeButton, 'Лайк...');
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@@ -680,6 +934,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -688,6 +943,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
const ratingButton = document.createElement('button');
ratingButton.type = 'button';
ratingButton.className = 'channel-action-item thread-rating-btn';
ratingButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оценка</span>
<span class="channel-action-counter">${ratings}</span>
`;
setActionTitle(ratingButton, 'Оценка');
ratingButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate: handlers.navigate,
mode: 'rating',
onSubmit: async (textValue) => handlers.onRating(target, textValue),
});
});
const shareButton = document.createElement('button');
shareButton.type = 'button';
@@ -696,6 +969,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -704,7 +978,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton);
actions.append(likeButton, replyButton, ratingButton, shareButton);
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
@@ -713,6 +987,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span>
`;
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
@@ -727,6 +1002,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
actions.append(originalButton);
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Данные блокчейна</span>
`;
setActionTitle(detailsButton, 'Данные блокчейна');
detailsButton.addEventListener('click', (event) => {
event.stopPropagation();
openBlockchainDetailsModal(buildBlockchainDetails({
target,
authorLogin: author,
timestampMs: node?.createdAtMs,
text,
raw: node,
localNumber,
msgSubType,
}));
});
actions.append(detailsButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -740,8 +1036,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(text || '').trim() === 'удалено' ? '' : text,
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
});
});
@@ -768,7 +1065,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
normalized.forEach((branch, index) => {
try {
const nodeNumber = nextNumber();
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4)));
wrap.append(row);
@@ -817,7 +1114,7 @@ function renderSkeleton(screen) {
return wrap;
}
export function render({ navigate, route }) {
export function render({ navigate, route, chrome }) {
const selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
@@ -837,6 +1134,7 @@ export function render({ navigate, route }) {
threadHeaderButton.classList.add('channel-header-route-btn');
threadHeaderButton.disabled = true;
}
chrome?.setTopbar(header);
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
@@ -913,6 +1211,15 @@ export function render({ navigate, route }) {
showStatus('');
rerender();
},
onRating: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Оценка отправлена');
showStatus('');
rerender();
},
onRepost: async (target) => {
const { login, storagePwd } = requireSigningSession();
const feed = await authService.listSubscriptionsFeed(login, 1000);
@@ -1003,7 +1310,7 @@ export function render({ navigate, route }) {
},
};
screen.append(header, statusBox);
screen.append(statusBox);
if (!selector) {
const invalid = document.createElement('div');
@@ -1135,12 +1442,9 @@ export function render({ navigate, route }) {
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'История выше (на что это ответ)';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
});
}
@@ -1159,7 +1463,7 @@ export function render({ navigate, route }) {
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы';
descendantsTitle.textContent = 'Ответы и оценки';
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
@@ -1167,7 +1471,7 @@ export function render({ navigate, route }) {
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ответов пока нет.';
empty.textContent = 'Ответов и оценок пока нет.';
descendantsWrap.append(empty);
}
File diff suppressed because it is too large Load Diff
+107 -28
View File
@@ -1,6 +1,7 @@
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js';
import {
animatePress,
createSkeletonCard,
@@ -12,6 +13,8 @@ import {
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -19,6 +22,15 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Дневник';
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
}
function isChannelsDemoMode() {
try {
@@ -610,7 +622,7 @@ function mapMockGroups() {
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id),
}),
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
messagePreview: cleanChannelMessagePreview(channel.lastMessage),
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
notificationsEnabled: false,
@@ -646,11 +658,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
const channelName = summary?.channel?.channelName || '(без названия)';
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
const isOwn = bucketKey === 'own';
const title = `${ownerLogin} / ${channelName}`;
const title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`;
return {
id: rowId,
@@ -659,13 +673,16 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
avatar: avatarLetterFromName(channelName),
avatar: avatarLetterFromName(displayTitle || channelName),
avaAr: String(summary?.channel?.avaAr || '').trim(),
title,
technicalLabel,
channelName,
displayTitle,
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
@@ -686,11 +703,64 @@ function pullCreateSuccessFlash() {
}
}
function mapApiFeed(feed, notificationsState) {
function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) {
const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
if (!messages.length) return null;
const ownerBlockchainName = String(
diaryPayload?.channel?.ownerBlockchainName
|| ownRows[0]?.channel?.ownerBlockchainName
|| ''
).trim();
if (!ownerBlockchainName) return null;
const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim();
const lastMessage = messages[messages.length - 1] || null;
const rowId = 'own-diary';
index[rowId] = { diary: true, payload: diaryPayload };
return {
id: rowId,
route: makeShineChannelRoute({
ownerLogin,
ownerBlockchainName,
channelName: DIARY_CHANNEL_NAME,
}),
ownerName: ownerLogin || 'я',
ownerBlockchainName,
channelRootBlockNumber: 0,
channelRootBlockHash: '0',
avatar: 'Д',
avaAr: '',
title: DIARY_DISPLAY_NAME,
technicalLabel: 'Виртуальная лента Дневника',
channelName: DIARY_CHANNEL_NAME,
displayTitle: DIARY_DISPLAY_NAME,
channelDescription: 'История действий по упражнениям, услугам и курсам',
channelTypeCode: 900,
channelTypeVersion: 1,
messagePreview: String(lastMessage?.text || '').trim()
? cleanChannelMessagePreview(lastMessage?.text)
: 'Новая запись в Дневнике',
messagesCount: messages.length,
unreadCount: 0,
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
isOwnChannel: true,
isSubscribed: false,
notificationsEnabled: notificationsState[rowId] === true,
pending: false,
};
}
function mapApiFeed(feed, notificationsState, diaryPayload = null) {
const index = {};
const ownChannels = (feed?.ownedChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
const diaryChannel = diaryPayload
? buildDiaryChannelRow(diaryPayload, feed?.ownedChannels || [], notificationsState, index)
: null;
if (diaryChannel) ownChannels.unshift(diaryChannel);
const followedUserChannels = (feed?.followedUsersChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
@@ -962,6 +1032,10 @@ function renderChannelMain(channel) {
title.className = 'channel-row-title';
title.textContent = channel.title;
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
if (channel.channelDescription) {
const desc = document.createElement('p');
desc.className = 'channel-row-description';
@@ -977,7 +1051,7 @@ function renderChannelMain(channel) {
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(title);
main.prepend(title, technical);
main.append(preview, meta);
return main;
}
@@ -1006,7 +1080,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = channel.avatar;
if (channel.avaAr) {
const img = document.createElement('img');
img.alt = '';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
avatar.append(img);
} else {
avatar.textContent = channel.avatar;
}
const main = renderChannelMain(channel);
@@ -1016,7 +1097,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
const count = document.createElement('span');
count.className = 'unread channel-row-count';
@@ -1069,11 +1150,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list);
}
function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
const baseClass = `primary-btn channels-bottom-action${isTabEmpty ? ' is-empty-lift' : ''}`;
button.textContent = 'Поиск каналов';
button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate });
function updateBottomCta({ button }) {
if (!button) return;
button.hidden = true;
button.textContent = '';
button.className = 'channels-bottom-action';
button.onclick = null;
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
@@ -1103,7 +1185,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed, listState.notificationsState);
let diaryPayload = null;
try {
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
} catch {
diaryPayload = null;
}
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
listState.channels = toListModel(groups);
setChannelsFeed(feed, groups.index);
@@ -1128,7 +1216,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
}
}
export function render({ navigate, route }) {
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen');
@@ -1222,26 +1310,17 @@ export function render({ navigate, route }) {
createInMyBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
updateBottomCta({
button: bottomCta,
listState,
navigate,
isTabEmpty,
});
updateBottomCta({ button: bottomCta });
};
screen.append(topBarEl, contentEl, bottomCta);
chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
updateBottomCta({
button: bottomCta,
listState,
navigate,
isTabEmpty: true,
});
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
+69 -42
View File
@@ -245,6 +245,8 @@ function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
@@ -255,7 +257,9 @@ function openChatActionsMenu({
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
@@ -303,6 +307,14 @@ function openChatActionsMenu({
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
close();
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -440,12 +452,12 @@ function buildCallStatusText(callSummary) {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Не дозвонился: абонент занят';
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
return 'Не дозвонился';
if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонок отклонён';
return 'Звонок не завершён';
}
function resolveEffectiveReadState(messages, msg) {
@@ -594,20 +606,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon';
callIcon.textContent = '☎';
callIcon.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming');
const callIconPhone = document.createElement('span');
callIconPhone.className = 'bubble-call-icon-phone';
callIconPhone.textContent = '✆';
const callIconArrow = document.createElement('span');
callIconArrow.className = 'bubble-call-icon-arrow';
callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓';
callIcon.append(callIconPhone, callIconArrow);
const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title';
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
callTitle.textContent = 'Звонок';
callHead.append(callIcon, callTitle);
const callMetaLine = document.createElement('div');
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
const callMetaLine = document.createElement('span');
callMetaLine.className = 'bubble-call-line';
callMetaLine.classList.add(
parsedText.callSummary.status === 'completed'
? 'bubble-call-line--success'
: 'bubble-call-line--plain',
);
const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus;
callCard.append(callHead, callMetaLine);
callHead.append(callIcon, callTitle, callMetaLine);
callCard.append(callHead);
bubble.append(callCard);
}
@@ -757,9 +783,10 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
}
export function render({ navigate, route }) {
export function render({ navigate, route, chrome }) {
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -789,9 +816,9 @@ export function render({ navigate, route }) {
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
};
const handleStartCall = async () => {
const handleStartCall = async (mode = 'audio') => {
try {
await startOutgoingCall(chatId);
await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
@@ -845,7 +872,7 @@ export function render({ navigate, route }) {
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
screen.append(
chrome?.setTopbar(
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
@@ -855,10 +882,10 @@ export function render({ navigate, route }) {
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
iconNode: createHeaderPhoneHandsetIcon(),
onClick: handleStartCall,
onClick: () => handleStartCall('audio'),
},
{
label: '',
label: '',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -866,7 +893,9 @@ export function render({ navigate, route }) {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall,
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -913,7 +942,7 @@ export function render({ navigate, route }) {
},
},
],
})
}),
);
if (!isKnownContact) {
@@ -963,7 +992,7 @@ export function render({ navigate, route }) {
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование"></button>
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph"></span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
@@ -982,11 +1011,24 @@ export function render({ navigate, route }) {
let emojiPickerOpen = false;
let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const appShell = document.querySelector('.app-shell');
const setKeyboardInset = (valuePx = 0) => {
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
};
const syncKeyboardUi = () => {
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const viewport = window.visualViewport || null;
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
const keyboardInset = viewportShrunk
? Math.max(0, baseViewportHeight - viewportHeight)
: 0;
setKeyboardInset(keyboardInset);
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (viewportShrunk && window.scrollY !== 0) {
window.scrollTo(0, 0);
}
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
@@ -1427,23 +1469,6 @@ export function render({ navigate, route }) {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
});
// Enter — отправить; Shift+Enter и Ctrl+Enter — перенос строки.
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.ctrlKey) {
return;
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
syncComposerLayout();
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
@@ -1479,7 +1504,8 @@ export function render({ navigate, route }) {
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
wrap.append(historyLoader, log, form);
chrome?.setComposer(form);
wrap.append(historyLoader, log);
screen.append(wrap);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
@@ -1499,6 +1525,7 @@ export function render({ navigate, route }) {
});
screen.cleanup = () => {
setChatKeyboardOpen(false);
setKeyboardInset(0);
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
+3 -2
View File
@@ -84,7 +84,7 @@ function formatChatRowTime(ts) {
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate }) {
export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
@@ -248,7 +248,8 @@ export function render({ navigate }) {
}
}
screen.append(head, divider, list);
chrome?.setTopbar(head);
screen.append(divider, list);
loadList();
return screen;
}
+2 -2
View File
@@ -87,13 +87,13 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
export function render({ navigate }) {
export function render({ navigate, chrome }) {
const login = state.session.login || profile.login;
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
screen.append(
chrome?.setTopbar(
renderHeader({
title: 'Редактирование профиля',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
+19 -15
View File
@@ -92,28 +92,32 @@ function shineInfoText() {
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
export function render({ navigate }) {
export function render({ navigate, chrome }) {
const login = state.session.login || profile.login;
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
const topActions = document.createElement('div');
topActions.className = 'profile-top-actions';
topActions.innerHTML = `
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</button>
const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = `
<div class="header-actions profile-top-actions">
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</button>
</div>
`;
const topActions = topbar.querySelector('.profile-top-actions');
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet'));
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack profile-main-card';
@@ -264,7 +268,7 @@ export function render({ navigate }) {
}
}
card.append(topActions, topRow, badgesRow, listWrap);
card.append(topRow, badgesRow, listWrap);
screen.append(card);
updateAvatarUi();
+30 -257
View File
@@ -4,34 +4,17 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { sha256Text } from '../services/crypto-utils.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
// ВРЕМЕННАЯ UI-ЗАГЛУШКА:
// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain,
// UI пускает логины 5..7 символов только по временному коду.
const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8;
const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5;
const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
function bytesToHex(bytes) {
return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join('');
}
async function isTemporaryPromoAccepted(value) {
const digest = await sha256Text(String(value || ''));
return bytesToHex(digest) === TEMP_PROMO_HASH_HEX;
}
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
function normalizeLoginForTemporaryUiGuard(login) {
const source = String(login || '').trim();
@@ -46,48 +29,6 @@ function normalizeLoginForTemporaryUiGuard(login) {
return normalized;
}
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
function makePasswordToggleIcons() {
return {
eye: `
@@ -115,10 +56,11 @@ export function render({ navigate }) {
const form = document.createElement('div');
form.className = 'card stack registration-form';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked);
let usePromoCode = Boolean(state.registrationDraft.usePromoCode);
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
let loginCheckTimer = 0;
let loginCheckRunId = 0;
@@ -155,67 +97,6 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton);
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
passwordInput.value = composePasswordFromWords(passwordWords);
passwordWordsLinked = true;
syncDraftState();
updateWordsPreview();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const promoToggle = document.createElement('label');
promoToggle.className = 'registration-toggle';
const promoCheckbox = document.createElement('input');
promoCheckbox.type = 'checkbox';
promoCheckbox.checked = usePromoCode;
const promoToggleLabel = document.createElement('span');
promoToggleLabel.textContent = 'У меня есть промокод';
promoToggle.append(promoCheckbox, promoToggleLabel);
const promoField = document.createElement('label');
promoField.className = 'stack';
const promoFieldLabel = document.createElement('span');
promoFieldLabel.className = 'field-label';
promoFieldLabel.textContent = 'Промокод';
const promoInput = document.createElement('input');
promoInput.className = 'input';
promoInput.type = 'text';
promoInput.autocomplete = 'off';
promoInput.autocapitalize = 'off';
promoInput.spellcheck = false;
promoInput.value = String(state.registrationDraft.promoCode || '');
promoInput.placeholder = 'Вставьте промокод';
const promoHint = document.createElement('p');
promoHint.className = 'meta-muted';
promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.';
promoField.append(promoFieldLabel, promoInput, promoHint);
const statusText = document.createElement('p');
statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none';
@@ -239,7 +120,6 @@ export function render({ navigate }) {
nextButton.type = 'button';
nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'password-length-hint';
let lastCheckedLogin = '';
@@ -251,13 +131,8 @@ export function render({ navigate }) {
return String(passwordInput.value || '');
}
function updateWordsPreview() {
const password = passwordMode === 'words'
? composePasswordFromWords(passwordWords)
: String(passwordInput.value || '');
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
function updatePasswordLength() {
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
}
function setStatusMessage(message, kind = '') {
@@ -295,32 +170,14 @@ export function render({ navigate }) {
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль');
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = 'grid';
passwordInputRow.style.display = 'grid';
updateWordsPreview();
}
function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.password = getCurrentPassword();
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = String(promoInput.value || '').trim();
}
function updatePromoVisibility() {
promoField.style.display = usePromoCode ? 'grid' : 'none';
}
async function runAvailabilityCheck({ automatic = false } = {}) {
const runId = ++loginCheckRunId;
const login = loginInput.value.trim();
const promoCode = String(promoInput.value || '').trim();
if (!login) {
setStatusMessage(automatic ? '' : 'Введите логин');
formError.style.display = 'none';
@@ -334,28 +191,8 @@ export function render({ navigate }) {
return false;
}
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) {
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO;
let promoAccepted = false;
if (usePromoCode) {
if (!promoCode) {
setStatusMessage('Введите промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
promoAccepted = await isTemporaryPromoAccepted(promoCode);
if (!promoAccepted) {
setStatusMessage('Неверный промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
} else if (wantsShortLogin) {
setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable');
if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
@@ -363,8 +200,6 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') {
@@ -375,7 +210,7 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
formError.style.display = 'none';
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo');
return lastCheckedFree && lastCheckedClassName === 'free';
}
setStatusMessage('Проверяем логин...');
@@ -388,19 +223,15 @@ export function render({ navigate }) {
const isFree = !check.exists;
let className = '';
if (isFree) {
if (promoAccepted) {
className = 'promo';
} else {
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
}
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
}
}
lastCheckedLogin = login;
@@ -408,8 +239,6 @@ export function render({ navigate }) {
lastCheckedClassName = className;
if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') {
@@ -420,9 +249,13 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
formError.style.display = 'none';
return isFree && (className === 'free' || className === 'promo');
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
@@ -438,65 +271,12 @@ export function render({ navigate }) {
});
passwordInput.addEventListener('input', () => {
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
passwordWords = emptyPasswordWords();
passwordWordsLinked = false;
wordInputs.forEach((input) => {
input.value = '';
});
}
syncDraftState();
updateWordsPreview();
});
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
if (!passwordWordsLinked) {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
} else if (passwordWordsLinked) {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState();
});
promoCheckbox.addEventListener('change', () => {
usePromoCode = promoCheckbox.checked;
resetLoginCheckState();
updatePromoVisibility();
syncDraftState();
if (usePromoCode) {
setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available');
} else {
setStatusMessage('');
scheduleAvailabilityCheck();
}
});
promoInput.addEventListener('input', () => {
resetLoginCheckState();
syncDraftState();
if (usePromoCode) scheduleAvailabilityCheck();
updatePasswordLength();
});
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const promoCode = String(promoInput.value || '').trim();
if (usePromoCode && !promoCode) {
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
formError.style.display = '';
return;
}
const isFree = await runAvailabilityCheck();
if (!isFree) return;
@@ -518,11 +298,6 @@ export function render({ navigate }) {
state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = promoCode;
if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null;
}
@@ -539,14 +314,12 @@ export function render({ navigate }) {
passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel);
passwordField = passwordLabel;
loginField.append(loginInput);
passwordField.append(passwordInputRow);
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton);
passwordLabel.append(passwordInputRow);
form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = '';
actions.append(nextButton);
updatePasswordModeVisibility();
updatePromoVisibility();
updatePasswordLength();
syncDraftState();
}
+1 -11
View File
@@ -62,17 +62,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
],
},
{
id: 'one-or-twelve',
shortTitle: '1 или 12 слов',
title: 'Чем отличается один пароль от режима 12 слов?',
paragraphs: [
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
'Используйте уникальный пароль, который не применяется в других сервисах.',
],
},
{
+20 -4
View File
@@ -18,6 +18,7 @@ import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
isUserAlreadyExistsSolanaError,
registerUserOnSolana,
} from '../services/solana-register-service.js';
@@ -311,6 +312,16 @@ export function render({ navigate }) {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.');
// Перед on-chain регистрацией перепроверяем логин ещё раз:
// между экраном выбора логина и оплатой его мог занять кто-то другой.
const loginStateBeforeRegister = await checkLoginExistsOnSolana({
login: state.registrationDraft.login,
solanaEndpoint: state.entrySettings.solanaServer,
});
if (loginStateBeforeRegister?.exists) {
throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
}
// Регистрация на Solana (смарт контракт)
submitButton.textContent = 'Регистрация в Solana...';
let registrationTxId = '';
@@ -325,15 +336,20 @@ export function render({ navigate }) {
registrationTxId = String(registrationResult?.signature || '').trim();
} catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError);
// Пользователь уже зарегистрирован в Solana — продолжаем
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) {
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
if (solanaMsg.includes('already') || isUserAlreadyExistsSolanaError(solanaError)) {
throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
}
if (isSolanaRpcUnavailableError(solanaError)) {
throw new Error('Нет связи с сервером Solana. Попробуйте ещё раз позже.');
}
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
}
renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
const message = isSolanaRpcUnavailableError(error)
? 'Нет связи с сервером Solana. Попробуйте ещё раз позже.'
: toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
+7
View File
@@ -54,6 +54,12 @@ export function render({ navigate }) {
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузить файлы в блокчейн</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
@@ -62,6 +68,7 @@ export function render({ navigate }) {
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
+15 -10
View File
@@ -1,4 +1,4 @@
import { parseShineRootSegment } from './services/shine-routes.js';
import { parseShineRouteParts } from './services/shine-routes.js';
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
const PRETTY_PATHS = new Map([
@@ -31,6 +31,7 @@ const PRETTY_PATHS = new Map([
['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'],
@@ -89,9 +90,11 @@ export function getRoute() {
};
const isLikelyBlockNumber = (value) => /^[0-9]+$/.test(String(value || '').trim());
const shineLogin = parseShineRootSegment(pageId);
if (shineLogin) {
const section = decodePart(segments[1] || '').toLowerCase();
const shineRoute = parseShineRouteParts(segments);
if (shineRoute?.entity) {
const shineLogin = shineRoute.entity;
const baseOffset = Number(shineRoute.offset || 0);
const section = decodePart(segments[baseOffset] || '').toLowerCase();
if (!section) {
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
}
@@ -99,7 +102,7 @@ export function getRoute() {
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
}
if (section === 'channels') {
const sub = decodePart(segments[2] || '').toLowerCase();
const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
if (sub === 'owned') {
return {
pageId: 'channels-list',
@@ -117,12 +120,12 @@ export function getRoute() {
params: { mode: 'feed', login: shineLogin, scope: 'all' },
};
}
if (segments.length === 2 && isLikelyBlockNumber(segments[1])) {
if (segments.length === baseOffset + 1 && isLikelyBlockNumber(segments[baseOffset])) {
return {
pageId: 'channel-thread-view',
params: {
messageBlockchainName: shineLogin,
messageBlockNumber: segments[1] || '',
messageBlockNumber: segments[baseOffset] || '',
messageBlockHash: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
@@ -130,10 +133,10 @@ export function getRoute() {
},
};
}
if (segments.length >= 2) {
if (segments.length >= baseOffset + 1) {
const ownerBlockchainName = shineLogin;
const channelName = decodePart(segments[1] || '');
const messageBlockNumber = segments[2] || '';
const channelName = decodePart(segments[baseOffset] || '');
const messageBlockNumber = segments[baseOffset + 1] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) {
return {
pageId: 'channel-thread-view',
@@ -261,6 +264,7 @@ export function getRoute() {
const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} };
@@ -373,6 +377,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
+39 -9
View File
@@ -1,6 +1,7 @@
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const WINSTON_PER_AR = 1_000_000_000_000n;
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -268,12 +269,42 @@ export async function prepareAvatarImageFile(file) {
}
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
export async function loadArweaveFileMetadata({ gateway, txId, maxBytes = MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES } = {}) {
const url = buildArweaveDataUrl({ gateway, txId });
const limit = Math.max(1, Number(maxBytes || MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES));
const response = await fetch(url, { method: 'GET', cache: 'no-store' });
if (!response.ok) {
throw new Error(`Не удалось скачать файл Arweave (${response.status} ${response.statusText})`);
}
const lengthHeader = String(response.headers.get('content-length') || '').trim();
const declaredSize = Number(lengthHeader);
if (Number.isFinite(declaredSize) && declaredSize > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const cleanType = String(response.headers.get('content-type') || '').split(';')[0].trim();
const sha256Hex = await sha256HexFromArrayBuffer(buffer);
return {
txId: String(txId || '').trim(),
sizeBytes: buffer.byteLength,
sha256Hex,
contentType: cleanType,
gateway: normalizeGateway(gateway),
};
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [], shineType = 'avatar' }) {
if (!jwk || typeof jwk !== 'object') {
throw new Error('Arweave-кошелёк не инициализирован.');
}
if (!(file instanceof File)) {
throw new Error('Выберите файл изображения.');
throw new Error('Выберите файл.');
}
const normalizedGateway = normalizeGateway(gateway);
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
const tx = await arweave.createTransaction({ data }, jwk);
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar');
tx.addTag('SHiNE-Type', String(shineType || 'file'));
const extraTags = Array.isArray(tags) ? tags : [];
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
const profileLogin = String(profileLoginTag?.value || '').trim();
if (!profileLogin) {
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
}
tx.addTag('SHiNE-Profile-Login', profileLogin);
extraTags.forEach((item) => {
const name = String(item?.name || '').trim();
const value = String(item?.value || '').trim();
if (name && value) tx.addTag(name, value);
});
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);
@@ -93,6 +93,53 @@ function pickCachedWallet(secrets) {
};
}
function normalizeExtraWallets(secrets) {
const rows = Array.isArray(secrets?.arweaveWallets) ? secrets.arweaveWallets : [];
return rows
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || item.address || '').trim(),
label: String(item.label || '').trim(),
address: String(item.address || '').trim(),
owner: String(item.owner || '').trim(),
jwk: item.jwk,
createdAtMs: Number(item.createdAtMs || 0),
}))
.filter((item) => item.id && item.address && item.jwk && typeof item.jwk === 'object');
}
function parseJwkSecretString(secret) {
const raw = String(secret || '').trim();
if (!raw) throw new Error('Введите секрет Arweave-кошелька.');
const tryParse = (value) => {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Некорректный JWK Arweave.');
}
return parsed;
};
if (raw.startsWith('{')) return tryParse(raw);
const normalized = raw
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(raw.length / 4) * 4, '=');
try {
return tryParse(atob(normalized));
} catch {
throw new Error('Секрет должен быть JSON JWK или base64url(JSON JWK).');
}
}
function validateArweaveJwk(jwk) {
if (!jwk || typeof jwk !== 'object') throw new Error('Некорректный JWK Arweave.');
['kty', 'e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi'].forEach((key) => {
if (!String(jwk[key] || '').trim()) throw new Error(`В JWK Arweave нет поля ${key}.`);
});
}
function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return;
try {
@@ -102,6 +149,63 @@ function safeStatus(onStatus, text) {
}
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
isDefault: true,
},
...extras.map((item) => ({
...item,
label: item.label || `Arweave ${item.address.slice(0, 6)}...${item.address.slice(-6)}`,
isDefault: false,
})),
];
}
export async function addArweaveWalletSecret({ login, storagePwd, secret, label = '' } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии.');
const jwk = parseJwkSecretString(secret);
validateArweaveJwk(jwk);
const moduleRef = await loadArweaveLib();
const Arweave = moduleRef?.default || moduleRef;
const arweave = Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' });
const address = await arweave.wallets.jwkToAddress(jwk);
const owner = String(jwk.n || '').trim();
const id = `manual-${address}`;
const createdAtMs = Date.now();
const wallet = {
id,
label: String(label || '').trim() || `Arweave ${address.slice(0, 6)}...${address.slice(-6)}`,
address,
owner,
jwk,
createdAtMs,
};
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (current) => {
const wallets = normalizeExtraWallets(current).filter((item) => item.address !== address);
wallets.push(wallet);
return {
...current,
arweaveWallets: wallets,
};
});
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
+421
View File
@@ -0,0 +1,421 @@
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
export const MAX_MESSAGE_ATTACHMENTS = 10;
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']);
export function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function normalizeName(name) {
const clean = String(name || 'file')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/]/g, '_')
.trim();
return clean || 'file';
}
function normalizePreview(input = {}) {
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
if (!previewTxId || !previewSha256Hex) return null;
if (!validateArweaveTxId(previewTxId)) return null;
if (!validateSha256Hex(previewSha256Hex)) return null;
return {
ar: previewTxId,
sha256: previewSha256Hex,
};
}
export function normalizeAttachment(input = {}) {
const txId = String(input.ar || input.txId || '').trim();
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const size = Number(input.size || input.sizeBytes || 0);
const name = normalizeName(input.name || input.fileName || 'file');
const preview = normalizePreview(input.preview || {
previewAr: input.previewAr,
previewSha256: input.previewSha256,
});
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
const out = {
v: 1,
name,
size,
sha256: sha256Hex,
ar: txId,
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
};
if (preview) {
out.v = 2;
out.preview = preview;
}
return out;
}
export function buildAttachmentBlock(attachment) {
const item = normalizeAttachment(attachment);
const encodedName = encodeURIComponent(item.name);
const previewFields = item.preview
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
: '';
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim();
const rows = (Array.isArray(attachments) ? attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(buildAttachmentBlock);
if (!rows.length) return cleanText;
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
}
function parseFields(rawFields) {
const out = {};
String(rawFields || '').split(';').forEach((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) out[key] = value;
});
return out;
}
export function parseMessageAttachments(rawText) {
let rest = String(rawText || '');
const attachments = [];
while (rest.startsWith(ATTACH_PREFIX)) {
const match = rest.match(ATTACH_BLOCK_RE);
if (!match) break;
const fields = parseFields(match[1]);
try {
attachments.push(normalizeAttachment({
name: decodeURIComponent(String(fields.name || 'file')),
size: fields.size,
sha256: fields.sha256,
ar: fields.ar,
previewAr: fields.previewAr,
previewSha256: fields.previewSha256,
}));
} catch {
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
}
rest = rest.slice(match[0].length);
}
return {
attachments,
text: rest.replace(/^\n+/u, ''),
};
}
export function getAttachmentExtension(name) {
const clean = String(name || '').trim().toLowerCase();
const dot = clean.lastIndexOf('.');
if (dot < 0 || dot === clean.length - 1) return '';
return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
}
export function getAttachmentKind(attachment = {}) {
const ext = getAttachmentExtension(attachment.name);
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
return 'file';
}
function isRecentMessage(messageTimestampMs) {
const ts = Number(messageTimestampMs || 0);
if (!Number.isFinite(ts) || ts <= 0) return false;
return Date.now() - ts < RECENT_UNAVAILABLE_MS;
}
function createUnavailableBlock(messageTimestampMs) {
const block = document.createElement('div');
block.className = 'message-attachment-unavailable';
block.textContent = isRecentMessage(messageTimestampMs)
? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.'
: 'Файл недоступен.';
return block;
}
function stopVideos(root) {
root.querySelectorAll('video').forEach((video) => {
try {
video.pause();
video.currentTime = 0;
} catch {
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
}
});
}
function createDownloadLink(url, label = 'Скачать') {
const link = document.createElement('a');
link.className = 'message-attachment-download';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.download = '';
link.textContent = label;
link.addEventListener('click', (event) => {
event.stopPropagation();
});
return link;
}
function openAttachmentViewer({ item, url, kind }) {
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'modal attachment-viewer-modal';
overlay.innerHTML = `
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
<div class="attachment-viewer-head">
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="attachment-viewer-body"></div>
<div class="attachment-viewer-actions"></div>
</div>
`;
const close = () => {
stopVideos(overlay);
overlay.remove();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
const body = overlay.querySelector('.attachment-viewer-body');
if (kind === 'video') {
const video = document.createElement('video');
video.className = 'attachment-viewer-media';
video.src = url;
video.controls = true;
video.autoplay = true;
body.append(video);
} else {
const img = document.createElement('img');
img.className = 'attachment-viewer-media';
img.src = url;
img.alt = item.name;
body.append(img);
}
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
root.append(overlay);
}
function replaceWithUnavailable(slide, messageTimestampMs) {
if (!slide || slide.dataset.unavailable === '1') return;
slide.dataset.unavailable = '1';
slide.replaceChildren(createUnavailableBlock(messageTimestampMs));
}
function createFileCard({ item, url, messageTimestampMs, slide }) {
const card = document.createElement('div');
card.className = 'message-attachment-file-card';
card.role = 'button';
card.tabIndex = 0;
card.title = 'Скачать файл';
card.innerHTML = `
<div class="message-attachment-file-ext">Файл</div>
<div class="message-attachment-file-main">
<div class="message-attachment-name">${escapeHtml(item.name)}</div>
<div class="message-attachment-meta">${escapeHtml(formatBytes(item.size))}</div>
</div>
`;
const openFile = () => window.open(url, '_blank', 'noopener');
card.addEventListener('click', (event) => {
event.stopPropagation();
openFile();
});
card.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openFile();
});
void fetch(url, { method: 'HEAD', cache: 'no-store' })
.then((response) => {
if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs);
})
.catch(() => replaceWithUnavailable(slide, messageTimestampMs));
return card;
}
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
const frame = document.createElement('div');
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
frame.role = 'button';
frame.tabIndex = 0;
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
frame.addEventListener('click', (event) => {
event.stopPropagation();
openAttachmentViewer({ item, url, kind });
});
frame.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openAttachmentViewer({ item, url, kind });
});
const bindImageLayout = (img) => {
img.addEventListener('load', () => {
const naturalWidth = Number(img.naturalWidth || 0);
const naturalHeight = Number(img.naturalHeight || 0);
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
img.classList.toggle('is-landscape', isLandscape);
frame.classList.toggle('is-landscape', isLandscape);
slide.classList.toggle('has-landscape-media', isLandscape);
slide.parentElement?.classList.toggle('has-landscape-media', isLandscape);
}, { once: true });
};
if (kind === 'image') {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = url;
img.alt = item.name;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
frame.append(img);
} else if (previewUrl) {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = previewUrl;
img.alt = `${item.name} preview`;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
play.textContent = '▶';
frame.append(img, play);
} else {
const video = document.createElement('video');
video.className = 'message-attachment-media';
video.src = url;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
play.textContent = '▶';
frame.append(video, play);
}
frame.append(createDownloadLink(url));
return frame;
}
export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) {
const items = (Array.isArray(attachments) ? attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map((raw) => {
try {
return normalizeAttachment(raw);
} catch {
return null;
}
})
.filter(Boolean);
const wrap = document.createElement('div');
wrap.className = 'message-attachments message-attachment-carousel';
wrap.addEventListener('click', (event) => {
event.stopPropagation();
});
if (!items.length) return wrap;
let index = 0;
let pointerStartX = 0;
const viewport = document.createElement('div');
viewport.className = 'message-attachment-carousel-viewport';
const slide = document.createElement('div');
slide.className = 'message-attachment-slide';
const prev = document.createElement('button');
prev.type = 'button';
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
prev.textContent = '';
prev.setAttribute('aria-label', 'Предыдущее вложение');
const next = document.createElement('button');
next.type = 'button';
next.className = 'message-attachment-arrow message-attachment-arrow--next';
next.textContent = '';
next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div');
counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
const render = () => {
stopVideos(wrap);
const item = items[index];
const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
viewport.classList.remove('has-landscape-media');
slide.className = 'message-attachment-slide';
slide.dataset.unavailable = '0';
slide.replaceChildren();
slide.append(kind === 'file'
? createFileCard({ item, url, messageTimestampMs, slide })
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.hidden = items.length <= 1;
counter.textContent = items.length > 1 ? `${index + 1} из ${items.length}` : '';
};
const move = (delta) => {
const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta));
if (nextIndex === index) return;
index = nextIndex;
render();
};
prev.addEventListener('click', (event) => {
event.stopPropagation();
move(-1);
});
next.addEventListener('click', (event) => {
event.stopPropagation();
move(1);
});
viewport.addEventListener('pointerdown', (event) => {
pointerStartX = event.clientX;
});
viewport.addEventListener('pointerup', (event) => {
const dx = event.clientX - pointerStartX;
if (Math.abs(dx) < 40) return;
move(dx < 0 ? 1 : -1);
});
viewport.append(slide, prev, next);
wrap.append(viewport, counter);
render();
return wrap;
}
export function createAttachmentListElement(attachments = [], options = {}) {
return createAttachmentCarouselElement(attachments, options);
}
+360 -130
View File
@@ -47,13 +47,29 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_REPOST = 30;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_REPOST = 50;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
const MSG_SUBTYPE_STATUS_LEARNED = 20;
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
const MSG_SUBTYPE_STATUS_STARTED = 120;
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@@ -64,6 +80,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
const SESSION_TYPE_CLIENT = 1;
const SESSION_TYPE_WALLET = 50;
const SESSION_TYPE_HOMESERVER = 100;
@@ -561,6 +578,67 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
);
}
function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
const cleanBch = String(toBlockchainName || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for rating');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for rating');
}
const message = String(text || '').trim();
if (!message) throw new Error('Rating text is required');
const bchBytes = utf8Bytes(cleanBch);
if (bchBytes.length < 1 || bchBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes');
}
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Rating text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(bchBytes.length),
bchBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
}
function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text = '' }) {
const cleanBch = String(toBlockchainName || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for status action');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for status action');
}
const bchBytes = utf8Bytes(cleanBch);
if (bchBytes.length < 1 || bchBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes');
}
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) {
throw new Error('Status action text must be 0..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(bchBytes.length),
bchBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
}
function makeTextRepostBodyBytes({
lineCode,
prevLineNumber,
@@ -690,14 +768,47 @@ function makeConnectionBodyBytes({
}
function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const bytes = utf8Bytes(text);
if (bytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
return text;
}
function validateChannelMetaTitle(value) {
const text = String(value || '').trim();
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
if (/[<>;\t\n\r\u0000]/u.test(text)) {
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
}
return text;
}
function normalizeChannelMetaDescription(value) {
const text = String(value == null ? '' : value).trim();
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
return text;
}
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
const cleanTitle = validateChannelMetaTitle(title);
const cleanDescription = normalizeChannelMetaDescription(description);
const rows = [];
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
if (avatar?.ar) {
const size = Number(avatar.size || 0);
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
const ar = String(avatar.ar || '').trim();
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
}
if (cleanDescription) rows.push(cleanDescription);
return rows.join('\n');
}
function validatePersonalChannelName(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
@@ -737,8 +848,8 @@ function makeCreateChannelBodyBytes({
}
const descriptionBytes = utf8Bytes(cleanDescription);
if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
@@ -762,31 +873,6 @@ function makeCreateChannelBodyBytes({
);
}
function makeCreateChannelBodyBytesLegacy({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName,
}) {
const check = validatePersonalChannelName(channelName);
if (!check.ok) throw new Error(check.error);
const cleanName = check.normalized;
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
throw new Error('Channel name must be 1..255 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(nameBytes.length),
nameBytes,
);
}
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
@@ -806,6 +892,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
);
}
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
const textBytes = utf8Bytes(message);
if (textBytes.length > 65535) {
throw new Error('Message text must be 0..65535 UTF-8 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int16Bytes(textBytes.length),
textBytes
);
}
function normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || '').trim();
const cleanBlockNumber = Number(target?.blockNumber);
@@ -1400,6 +1503,13 @@ export class AuthService {
return response.payload || {};
}
async getPersonalDiary(login, limit = 200, sort = 'asc') {
const payload = { login: String(login || '').trim(), limit, sort };
const response = await this.ws.request('GetPersonalDiary', payload);
if (response.status !== 200) throw opError('GetPersonalDiary', response);
return response.payload || {};
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
@@ -1741,6 +1851,71 @@ export class AuthService {
});
}
async addBlockRating({ login, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'rating');
const key = `rating:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextRatingBodyBytes({
toBlockchainName: target.blockchainName,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_RATING,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockStatusAction({ login, message, text = '', statusSubType, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim();
const cleanSubType = Number(statusSubType);
const target = normalizeMessageRefTarget(message, 'status action');
const allowedStatusSubTypes = new Set([
MSG_SUBTYPE_STATUS_DONE_ONCE,
MSG_SUBTYPE_STATUS_LEARNED,
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
MSG_SUBTYPE_STATUS_CONFIRMED,
MSG_SUBTYPE_STATUS_INTERESTED,
MSG_SUBTYPE_STATUS_STARTED,
MSG_SUBTYPE_STATUS_IN_STUDY,
MSG_SUBTYPE_STATUS_ABANDONED,
MSG_SUBTYPE_STATUS_COMPLETED,
]);
if (!allowedStatusSubTypes.has(cleanSubType)) {
throw new Error('Unsupported status action subtype');
}
const key = `status-action:${cleanLogin}:${cleanSubType}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const bodyBytes = makeStatusActionBodyBytes({
toBlockchainName: target.blockchainName,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_STATUS_ACTION,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
});
}
async addBlockRepost({ login, channel, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -2033,6 +2208,8 @@ export class AuthService {
login,
channelName,
channelDescription = '',
channelProfileTitle = '',
channelAvatar = null,
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd,
@@ -2048,7 +2225,13 @@ export class AuthService {
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
}
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
const cleanChannelDescription = typeCode === CHANNEL_TYPE_PUBLIC
? composeChannelMetaText({
title: channelProfileTitle,
description: channelDescription,
avatar: channelAvatar,
})
: normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
@@ -2080,51 +2263,23 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
let payload;
try {
payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
} catch (error) {
const rawCode = String(error?.code || '').toUpperCase();
const rawText = String(error?.message || '').toLowerCase();
const isLegacyFormatMismatch = (
rawCode === 'BAD_BLOCK_FORMAT' ||
rawText.includes('bad_block_format') ||
rawText.includes('некорректный формат блока')
);
if (!isLegacyFormatMismatch) throw error;
// Совместимость со старыми серверами, где CreateChannel body без description/type.
payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: 1,
bodyBytes: makeCreateChannelBodyBytesLegacy({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
}),
});
}
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
const selector = {
ownerBlockchainName: blockchainName,
@@ -2141,66 +2296,100 @@ export class AuthService {
});
}
async addBlockTextPost({ login, channel, text, storagePwd }) {
async resolveChannelLineTail({ login, blockchainName, channel }) {
const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber);
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector');
}
if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
}
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
}, 1, 'desc', login);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
.slice()
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
? latestMeta
: latestMessage;
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
const latestLineStep = resolveLatestLineStep(latest);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
thisLineNumber = latest?.kind === 'created'
? 0
: Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: (latestBlockNumber === lineCode ? 0 : 1);
}
} catch {
// fallback to root anchor
}
return {
ownerBlockchainName,
lineCode,
rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
};
}
async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
const cleanSubType = Number(msgSubType || MSG_SUBTYPE_TEXT_POST);
const allowedTextSubTypes = new Set([
MSG_SUBTYPE_TEXT_POST,
MSG_SUBTYPE_TEXT_ENTRYPOINT,
MSG_SUBTYPE_TEXT_EXERCISE,
MSG_SUBTYPE_TEXT_SERVICE,
MSG_SUBTYPE_TEXT_COURSE,
]);
if (!allowedTextSubTypes.has(cleanSubType)) {
throw new Error('Unsupported channel text subtype');
}
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const ownerBlockchainName = owner;
const lineCode = root;
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector');
}
if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
}
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
}, 1, 'desc', cleanLogin);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
const latestLineStep = resolveLatestLineStep(latestMessage);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
// Для нового POST берём следующий шаг после последнего сообщения линии.
thisLineNumber = Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: 1;
}
} catch {
// fallback to root anchor
}
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextPostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: cleanText,
});
@@ -2208,7 +2397,7 @@ export class AuthService {
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_POST,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
@@ -2216,9 +2405,50 @@ export class AuthService {
return {
...payload,
channel: {
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: tail.rootHashHex,
},
};
});
}
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const metaText = composeChannelMetaText({ title, description, avatar });
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: metaText,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
msgVersion: 1,
bodyBytes,
});
return {
...payload,
channel: {
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: tail.rootHashHex,
},
};
});
@@ -2560,8 +2790,8 @@ export class AuthService {
}
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
return response.payload || {};
}
File diff suppressed because it is too large Load Diff
+252 -8
View File
@@ -2,10 +2,13 @@ import {
acceptIncomingCall,
declineIncomingCall,
hangupActiveCall,
setCameraEnabled,
setMicMuted,
subscribeCallState,
toggleSpeakerMode,
} from './call-service.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from './user-profile-params.js';
const MINIMIZED_BAR_HEIGHT_PX = 44;
@@ -15,10 +18,20 @@ let panelEl = null;
let headerEl = null;
let titleEl = null;
let statusEl = null;
let mediaStageEl = null;
let remoteVideoEl = null;
let localPreviewWrapEl = null;
let localVideoEl = null;
let heroEl = null;
let avatarSlotEl = null;
let peerLoginEl = null;
let kindEl = null;
let topStatusEl = null;
let incomingActionsEl = null;
let activeActionsEl = null;
let speakerBtn = null;
let muteBtn = null;
let cameraBtn = null;
let acceptBtn = null;
let declineBtn = null;
let hangupBtn = null;
@@ -28,11 +41,22 @@ let barTextEl = null;
let barActionsEl = null;
let barSpeakerBtn = null;
let barMuteBtn = null;
let barCameraBtn = null;
let barHangupBtn = null;
let unbind = null;
let tickerId = 0;
let currentSnapshot = null;
let minimized = false;
let activeAvatarLogin = '';
let activeAvatarLoadToken = 0;
let localPreviewDragPointerId = null;
let localPreviewDragRect = null;
let localPreviewDragOffsetX = 0;
let localPreviewDragOffsetY = 0;
let localPreviewPosition = null;
const avatarSnapshotCache = new Map();
const avatarPendingByLogin = new Map();
function resolveAppShell() {
return document.querySelector('.app-shell') || document.body;
@@ -60,7 +84,68 @@ function getSnapshotElapsedMs(snapshot) {
function buildBarText(snapshot) {
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
return `Звонок с ${peer} · ${elapsed}`;
const kind = String(snapshot?.titleText || 'Звонок').trim() || 'Звонок';
return `${kind} с ${peer} · ${elapsed}`;
}
async function loadAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (avatarSnapshotCache.has(key)) return avatarSnapshotCache.get(key);
if (avatarPendingByLogin.has(key)) return avatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
avatarSnapshotCache.set(key, snapshot || null);
avatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
avatarSnapshotCache.set(key, null);
avatarPendingByLogin.delete(key);
return null;
});
avatarPendingByLogin.set(key, pending);
return pending;
}
function renderPeerAvatar(login, avatar = null) {
const hasAvatar = Boolean(String(avatar?.ar || '').trim());
const avatarEl = renderUserAvatar({
login: String(login || '').trim() || 'unknown',
avatar,
size: 'large',
className: 'call-peer-avatar',
title: login ? `Профиль ${login}` : 'Профиль собеседника',
});
if (!hasAvatar) {
avatarEl.classList.add('call-peer-avatar--empty');
const fallbackEl = avatarEl.querySelector('.avatar-fallback');
if (fallbackEl) fallbackEl.textContent = '';
}
return avatarEl;
}
function updatePeerAvatar(login) {
if (!avatarSlotEl) return;
const cleanLogin = String(login || '').trim();
if (activeAvatarLogin === cleanLogin && avatarSlotEl.childElementCount > 0) return;
activeAvatarLogin = cleanLogin;
const loadToken = ++activeAvatarLoadToken;
avatarSlotEl.innerHTML = '';
avatarSlotEl.append(renderPeerAvatar(cleanLogin));
if (!cleanLogin) return;
void loadAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarSlotEl || loadToken !== activeAvatarLoadToken || activeAvatarLogin !== cleanLogin) return;
const avatar = snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot.avatar.sha256Hex || '').trim().toLowerCase(),
}
: null;
const upgraded = renderPeerAvatar(cleanLogin, avatar);
avatarSlotEl.replaceChildren(upgraded);
});
}
function stopEvent(event) {
@@ -68,6 +153,10 @@ function stopEvent(event) {
event.stopPropagation();
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getIconSvg(name) {
if (name === 'mic-on') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
@@ -87,6 +176,12 @@ function getIconSvg(name) {
if (name === 'speaker-off') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
if (name === 'camera-on') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
}
if (name === 'camera-off') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
return '';
}
@@ -113,6 +208,15 @@ function updateSpeakerButtons(enabled) {
setButtonIcon(barSpeakerBtn, iconName, label);
}
function updateCameraButtons(enabled) {
const iconName = enabled ? 'camera-on' : 'camera-off';
const label = enabled ? 'Выключить камеру' : 'Включить камеру';
setButtonIcon(cameraBtn, iconName, label);
setButtonIcon(barCameraBtn, iconName, label);
if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0';
if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0';
}
async function handleMuteClick(event) {
stopEvent(event);
const nowMuted = event.currentTarget?.dataset?.muted === '1';
@@ -134,6 +238,14 @@ async function handleSpeakerClick(event) {
await toggleSpeakerMode();
}
async function handleCameraClick(event) {
stopEvent(event);
const enabled = event.currentTarget?.dataset?.enabled === '1';
try {
await setCameraEnabled(!enabled);
} catch {}
}
function setMinimized(nextValue) {
minimized = Boolean(nextValue);
render();
@@ -153,6 +265,60 @@ function ensureTicker() {
}
}
function applyLocalPreviewPosition() {
if (!localPreviewWrapEl) return;
if (!localPreviewPosition) {
localPreviewWrapEl.style.left = '';
localPreviewWrapEl.style.top = '';
localPreviewWrapEl.style.right = '';
localPreviewWrapEl.style.bottom = '';
return;
}
localPreviewWrapEl.style.left = `${localPreviewPosition.leftPx}px`;
localPreviewWrapEl.style.top = `${localPreviewPosition.topPx}px`;
localPreviewWrapEl.style.right = 'auto';
localPreviewWrapEl.style.bottom = 'auto';
}
function updateLocalPreviewPositionFromPointer(clientX, clientY) {
if (!mediaStageEl || !localPreviewWrapEl || !localPreviewDragRect) return;
const stageRect = mediaStageEl.getBoundingClientRect();
const width = localPreviewDragRect.width;
const height = localPreviewDragRect.height;
const nextLeft = clamp(clientX - stageRect.left - localPreviewDragOffsetX, 8, Math.max(8, stageRect.width - width - 8));
const nextTop = clamp(clientY - stageRect.top - localPreviewDragOffsetY, 8, Math.max(8, stageRect.height - height - 8));
localPreviewPosition = {
leftPx: Math.round(nextLeft),
topPx: Math.round(nextTop),
};
applyLocalPreviewPosition();
}
function handleLocalPreviewPointerDown(event) {
if (!localPreviewWrapEl || event.pointerType === 'mouse' && event.button !== 0) return;
localPreviewDragPointerId = event.pointerId;
localPreviewDragRect = localPreviewWrapEl.getBoundingClientRect();
localPreviewDragOffsetX = event.clientX - localPreviewDragRect.left;
localPreviewDragOffsetY = event.clientY - localPreviewDragRect.top;
try { localPreviewWrapEl.setPointerCapture(event.pointerId); } catch {}
stopEvent(event);
}
function handleLocalPreviewPointerMove(event) {
if (localPreviewDragPointerId !== event.pointerId) return;
updateLocalPreviewPositionFromPointer(event.clientX, event.clientY);
stopEvent(event);
}
function stopLocalPreviewDrag(event = null) {
if (event && localPreviewDragPointerId !== event.pointerId) return;
if (event && localPreviewWrapEl) {
try { localPreviewWrapEl.releasePointerCapture(event.pointerId); } catch {}
}
localPreviewDragPointerId = null;
localPreviewDragRect = null;
}
function ensureUi() {
if (rootEl) return;
@@ -185,12 +351,17 @@ function ensureUi() {
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barMuteBtn.addEventListener('click', handleMuteClick);
barCameraBtn = document.createElement('button');
barCameraBtn.type = 'button';
barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barCameraBtn.addEventListener('click', handleCameraClick);
barHangupBtn = document.createElement('button');
barHangupBtn.type = 'button';
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
barHangupBtn.addEventListener('click', handleEndCallClick);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn);
barEl.append(barTextEl, barActionsEl);
overlayEl = document.createElement('div');
@@ -203,7 +374,7 @@ function ensureUi() {
headerEl = document.createElement('div');
headerEl.className = 'call-overlay-head';
titleEl = document.createElement('h2');
titleEl = document.createElement('div');
titleEl.className = 'call-overlay-title';
minimizeBtn = document.createElement('button');
@@ -219,6 +390,47 @@ function ensureUi() {
statusEl = document.createElement('div');
statusEl.className = 'call-overlay-status';
mediaStageEl = document.createElement('div');
mediaStageEl.className = 'call-video-stage';
remoteVideoEl = document.createElement('video');
remoteVideoEl.className = 'call-remote-video';
remoteVideoEl.autoplay = true;
remoteVideoEl.playsInline = true;
remoteVideoEl.muted = true;
localPreviewWrapEl = document.createElement('div');
localPreviewWrapEl.className = 'call-local-preview';
localPreviewWrapEl.addEventListener('pointerdown', handleLocalPreviewPointerDown);
localPreviewWrapEl.addEventListener('pointermove', handleLocalPreviewPointerMove);
localPreviewWrapEl.addEventListener('pointerup', stopLocalPreviewDrag);
localPreviewWrapEl.addEventListener('pointercancel', stopLocalPreviewDrag);
localVideoEl = document.createElement('video');
localVideoEl.className = 'call-local-video';
localVideoEl.autoplay = true;
localVideoEl.playsInline = true;
localVideoEl.muted = true;
heroEl = document.createElement('div');
heroEl.className = 'call-hero';
avatarSlotEl = document.createElement('div');
avatarSlotEl.className = 'call-peer-avatar-slot';
peerLoginEl = document.createElement('div');
peerLoginEl.className = 'call-peer-login';
kindEl = document.createElement('div');
kindEl.className = 'call-kind';
topStatusEl = document.createElement('div');
topStatusEl.className = 'call-hero-status';
heroEl.append(avatarSlotEl, peerLoginEl, kindEl, topStatusEl);
localPreviewWrapEl.append(localVideoEl);
mediaStageEl.append(remoteVideoEl, heroEl, localPreviewWrapEl);
incomingActionsEl = document.createElement('div');
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
@@ -235,6 +447,11 @@ function ensureUi() {
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
muteBtn.addEventListener('click', handleMuteClick);
cameraBtn = document.createElement('button');
cameraBtn.type = 'button';
cameraBtn.className = 'call-icon-btn call-icon-btn--secondary';
cameraBtn.addEventListener('click', handleCameraClick);
acceptBtn = document.createElement('button');
acceptBtn.type = 'button';
acceptBtn.className = 'call-accept-btn';
@@ -258,9 +475,9 @@ function ensureUi() {
hangupBtn.addEventListener('click', handleEndCallClick);
incomingActionsEl.append(acceptBtn, declineBtn);
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn);
activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn);
headerEl.append(titleEl, minimizeBtn);
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl);
panelEl.append(mediaStageEl, headerEl, statusEl, incomingActionsEl, activeActionsEl);
overlayEl.append(panelEl);
rootEl.append(barEl, overlayEl);
resolveAppShell().append(rootEl);
@@ -270,13 +487,21 @@ function applyExpandedState(snapshot) {
overlayEl.hidden = false;
barEl.hidden = true;
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
statusEl.textContent = snapshot.statusText || '';
titleEl.textContent = '';
statusEl.textContent = '';
peerLoginEl.textContent = snapshot.peerLogin || 'пользователь';
kindEl.textContent = snapshot.titleText || 'Звонок';
topStatusEl.textContent = snapshot.statusText || '';
updatePeerAvatar(snapshot.peerLogin || '');
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
updateCameraButtons(Boolean(snapshot.cameraEnabled));
panelEl.classList.toggle('call-overlay-panel--remote-video', Boolean(snapshot.hasRemoteVideo));
mediaStageEl.classList.toggle('call-video-stage--remote-video', Boolean(snapshot.hasRemoteVideo));
heroEl.hidden = Boolean(snapshot.hasRemoteVideo);
minimizeBtn.hidden = incomingStage;
minimizeBtn.disabled = incomingStage;
@@ -290,9 +515,25 @@ function applyExpandedState(snapshot) {
muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute;
cameraBtn.hidden = !snapshot.canToggleCamera;
cameraBtn.disabled = !snapshot.canToggleCamera;
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup;
mediaStageEl.hidden = false;
const remoteStream = snapshot.remoteMediaStream || null;
const localStream = snapshot.localPreviewStream || null;
const remoteVideoSource = snapshot.hasRemoteVideo ? remoteStream : null;
if (remoteVideoEl.srcObject !== remoteVideoSource) remoteVideoEl.srcObject = remoteVideoSource;
if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream;
remoteVideoEl.hidden = !snapshot.hasRemoteVideo;
localPreviewWrapEl.hidden = !snapshot.hasLocalVideo;
if (!snapshot.hasLocalVideo) {
localPreviewPosition = null;
applyLocalPreviewPosition();
}
}
function applyMinimizedState(snapshot) {
@@ -303,15 +544,18 @@ function applyMinimizedState(snapshot) {
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
updateCameraButtons(Boolean(snapshot.cameraEnabled));
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute;
barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera;
barCameraBtn.disabled = !snapshot.canToggleCamera;
const canEnd = !incomingStage && snapshot.canHangup;
barHangupBtn.hidden = !canEnd;
barHangupBtn.disabled = !canEnd;
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd);
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd);
}
function syncShellState() {
+10 -7
View File
@@ -47,12 +47,12 @@ function buildCallDisplayText(callSummary) {
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
return 'Звонил, но недозвонился';
if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонок отклонён';
return 'Звонок не завершён';
}
export function sanitizeUserDmTextForSend(rawText = '') {
@@ -118,7 +118,10 @@ export function parseDmTechBlocks(rawText = '') {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
callSummary = {
status: 'completed',
durationSec,
};
} else {
callSummary = {
status: 'failed',
+28 -12
View File
@@ -10,6 +10,8 @@ function decodeRoutePart(value = '') {
}
}
const SHINE_ROUTE_SEGMENT = 'SHiNE';
export function normalizeLogin(value = '') {
return String(value || '').trim().replace(/^@+/, '');
}
@@ -23,21 +25,21 @@ export function extractLoginFromBlockchainName(value = '') {
export function makeProfileRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile';
return clean ? `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}` : 'profile';
}
export function makeProfileLinksRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network/keep-history';
return clean ? `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/links` : 'network/keep-history';
}
export function makeProfileChannelsRoute(login = '', scope = 'all') {
const clean = normalizeLogin(login);
if (!clean) return 'channels/feed';
const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
return `shine.${encodeRoutePart(clean)}/channels`;
if (normalizedScope === 'owned') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/following`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels`;
}
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
@@ -45,19 +47,33 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!ownerBch || !chName) return '';
if (msgNo) return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
if (msgNo) return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!msgBch || !msgNo) return '';
return `shine.${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
}
export function parseShineRootSegment(segment = '') {
const raw = String(segment || '').trim();
if (!raw.toLowerCase().startsWith('shine.')) return '';
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
export function parseShineRouteParts(segments = []) {
const parts = Array.isArray(segments) ? segments.map((value) => String(value || '').trim()) : [];
if (!parts.length) return null;
if (String(parts[0] || '').toLowerCase() === SHINE_ROUTE_SEGMENT.toLowerCase()) {
const entity = normalizeLogin(decodeRoutePart(parts[1] || ''));
if (!entity) return null;
return { entity, offset: 2, format: 'slash' };
}
const raw = String(parts[0] || '').trim();
const legacyPrefix = `${SHINE_ROUTE_SEGMENT}.`;
if (!raw.toLowerCase().startsWith(legacyPrefix.toLowerCase())) return null;
return {
entity: normalizeLogin(decodeRoutePart(raw.slice(legacyPrefix.length))),
offset: 1,
format: 'dot',
};
}
@@ -69,7 +69,7 @@ export async function resolveShineServerForUserLogin({ login, solanaEndpoint, fa
solanaEndpoint,
});
const accessServers = Array.isArray(parsedUser?.accessServers)
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean)
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 2)
: [];
const pickedServerLogin = accessServers[0] || normalizeShineServerLogin(fallbackServerLogin);
if (!pickedServerLogin) {
+18 -3
View File
@@ -30,6 +30,7 @@ const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1;
const SESSION_TYPE_HOMESERVER = 100;
const MAX_EFFECTIVE_ACCESS_SERVERS = 2;
let solanaLibPromise = null;
function loadSolanaLib() {
@@ -41,6 +42,19 @@ function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
function normalizeAccessServers(values) {
const seen = new Set();
const result = [];
for (const value of Array.isArray(values) ? values : []) {
const login = normalizeLogin(value);
if (!login || seen.has(login)) continue;
seen.add(login);
result.push(login);
if (result.length >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
}
return result;
}
function pushU32LE(buf, value) {
const n = Number(value) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
@@ -202,7 +216,7 @@ function serializeCreateUserPdaArgs(args) {
buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value);
}
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
const accessServers = normalizeAccessServers(args.accessServers);
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
@@ -252,7 +266,7 @@ function serializeUpdateUserPdaArgs(args) {
buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value);
}
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
const accessServers = normalizeAccessServers(args.accessServers);
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
@@ -337,7 +351,7 @@ function createPdaState({
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
serverAddress: serverProfile?.serverAddress ?? '',
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
accessServers: Array.isArray(accessServers) ? [...accessServers] : [],
accessServers: normalizeAccessServers(accessServers),
sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff,
sessions: Array.isArray(sessions) ? sessions.map((x) => ({
sessionType: Number(x?.sessionType || 0) & 0xff,
@@ -493,6 +507,7 @@ export function parseShineUserPda(dataBytes) {
const accessCount = reader.readU8();
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
accessServers = normalizeAccessServers(accessServers);
continue;
}
if (blockType === BLOCK_TYPE_SESSIONS) {
@@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) {
return parts.join(' :: ');
}
export function isSolanaRpcUnavailableError(error) {
const details = formatSolanaErrorDetails(error).toLowerCase();
return details.includes('service unavailable')
|| details.includes('503')
|| details.includes('failed to fetch')
|| details.includes('fetch failed')
|| details.includes('networkerror')
|| details.includes('network error')
|| details.includes('rpc unavailable')
|| details.includes('rpc error')
|| details.includes('failed to get info about account');
}
export function isUserAlreadyExistsSolanaError(error) {
const details = formatSolanaErrorDetails(error);
return details.includes('UserAlreadyExists')

Some files were not shown because too many files have changed in this diff Show More