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 -> { case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_POST & 0xFFFF) if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_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); yield new TextLineBody(subType, version, bodyBytes);
} }
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
yield new TextReplyBody(subType, version, bodyBytes); 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); throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
} }
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(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( default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d", "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; 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) + текст комментария. * Имеет 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) ===================== */ /* ===================== REACTION (msg_type=2) ===================== */
@@ -138,4 +156,16 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1; 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 * [1] channelNameLen
* [N] channelName UTF-8 * [N] channelName UTF-8
* [2] channelDescriptionLen * [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes) * [M] channelDescription UTF-8 (0..2048 bytes)
* [2] channelTypeCode (uint16) * [2] channelTypeCode (uint16)
* [2] channelTypeVersion (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 byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 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 subType;
public final short version; public final short version;
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int descriptionLen = Short.toUnsignedInt(bb.getShort()); int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) { 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) { if (bb.remaining() != descriptionLen + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen); 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); String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8); byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { 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); int typeCode = Short.toUnsignedInt(channelTypeCode);
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static String normalizeDescription(String value) { private static String normalizeDescription(String value) {
if (value == null) return ""; if (value == null) return "";
return value.trim().replaceAll("\\s+", " "); return value.trim().replace("\r\n", "\n").replace('\r', '\n');
} }
@Override @Override
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
} }
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8); byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { 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; 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 @Override
public int lineSeq() { return thisLineNumber; } 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: * subType:
* - POST (10) * - POST (10)
* - EDIT_POST (11) * - EDIT_POST (11)
* - REPOST (30) * - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* *
* POST: * POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = this.subType & 0xFFFF; int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { && st != (MsgSubType.TEXT_REPOST & 0xFFFF)
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st); && 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); 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.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"); ensureNoTail(bb, "TextLineBody");
} }
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { && st != (MsgSubType.TEXT_REPOST & 0xFFFF)
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST"); && 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 (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"); throw new IllegalArgumentException("message is blank");
} }
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_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); throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); 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) if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid"); throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
} else { } 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"); 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) 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; 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)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF; 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"); throw new IllegalArgumentException("Text payload is empty");
} }
int cap; 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; cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST // EDIT_POST
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); 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) { private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) { 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_1 = 1;
public static final int SCHEMA_VERSION_2 = 2; public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3; 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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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_V3_RESOURCE = "postgres/migration_v3.sql";
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -30,7 +32,13 @@ public final class DatabaseInitializer {
public static final short TEXT_EDIT_POST = 11; public static final short TEXT_EDIT_POST = 11;
public static final short TEXT_REPLY = 20; public static final short TEXT_REPLY = 20;
public static final short TEXT_EDIT_REPLY = 21; 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_LIKE = 1;
public static final short REACTION_UNLIKE = 2; public static final short REACTION_UNLIKE = 2;
@@ -88,6 +96,10 @@ public final class DatabaseInitializer {
} }
if (currentVersion < SCHEMA_VERSION_3) { if (currentVersion < SCHEMA_VERSION_3) {
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE); 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 — редактирование исходного ответа. */ /** EDIT_REPLY — редактирование исходного ответа. */
public static final short TEXT_EDIT_REPLY = 21; public static final short TEXT_EDIT_REPLY = 21;
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */ /** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_REPOST = 30; 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) ===================== */ /* ===================== REACTION (msg_type=2) ===================== */
@@ -120,6 +135,18 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1; 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 — лучше добавить новые значения, // Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
// не трогая уже занятые коды. // не трогая уже занятые коды.
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
channel_type_version, channel_type_version,
channel_root_block_number, channel_root_block_number,
channel_root_block_hash, channel_root_block_hash,
created_at_ms created_at_ms,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ava_ar,
ava_sha256,
ava_size,
meta_updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug()); ps.setString(1, entry.getSlug());
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
ps.setInt(8, entry.getChannelRootBlockNumber()); ps.setInt(8, entry.getChannelRootBlockNumber());
ps.setBytes(9, entry.getChannelRootBlockHash()); ps.setBytes(9, entry.getChannelRootBlockHash());
ps.setLong(10, entry.getCreatedAtMs()); 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(); ps.executeUpdate();
} }
} }
@@ -13,7 +13,7 @@ import java.util.List;
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя": * Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
* - login цели (channelLogin) * - login цели (channelLogin)
* - blockchainName цели (channelBchName) * - blockchainName цели (channelBchName)
* - count публикаций (TEXT_POST) * - count публикаций (видимые line-based TEXT-сообщения канала)
* - last publication: bytes оригинального блока (для timestamp) * - last publication: bytes оригинального блока (для timestamp)
* - last publication: bytes актуального блока (edit или orig) — для текста превью * - last publication: bytes актуального блока (edit или orig) — для текста превью
* *
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
/** /**
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой: * Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
* - count публикаций (TEXT_POST) * - count публикаций (видимые line-based TEXT-сообщения канала)
* - последнюю публикацию (orig bytes) + её edit (если есть) * - последнюю публикацию (orig bytes) + её edit (если есть)
* *
* Поведение при 0 публикаций: * Поведение при 0 публикаций:
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ? WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?) AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name GROUP BY b.login, b.bch_name
), ),
last_pub AS ( last_pub AS (
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ? WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?) AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name GROUP BY b.login, b.bch_name
), ),
last_pub_block AS ( last_pub_block AS (
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); 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 // last_pub
ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); 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()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
private int channelRootBlockNumber; private int channelRootBlockNumber;
private byte[] channelRootBlockHash; private byte[] channelRootBlockHash;
private long createdAtMs; private long createdAtMs;
private String avaAr;
private String avaSha256;
private long avaSize;
private long metaUpdatedAtMs;
public String getSlug() { public String getSlug() {
return slug; return slug;
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
public void setCreatedAtMs(long createdAtMs) { public void setCreatedAtMs(long createdAtMs) {
this.createdAtMs = 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, s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE AND s.is_server = TRUE
@@ -92,12 +96,16 @@ BEGIN
FOR affected_user IN FOR affected_user IN
SELECT u.login SELECT u.login
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) 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) WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); 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, s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE AND s.is_server = TRUE
@@ -201,12 +205,16 @@ BEGIN
FOR affected_user IN FOR affected_user IN
SELECT u.login SELECT u.login
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) 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) WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); 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_type_version INTEGER NOT NULL DEFAULT 1,
channel_root_block_number INTEGER NOT NULL, channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BYTEA 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 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.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_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_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_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_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_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_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_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; 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("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_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("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_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 ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient(); private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE"; private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
private SolanaUserPdaImportService() {} private SolanaUserPdaImportService() {}
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
String serverAddress = safe(serverProfile.serverAddress()); String serverAddress = safe(serverProfile.serverAddress());
if (serverAddress.isBlank()) continue; if (serverAddress.isBlank()) continue;
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress)); routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
} }
return new ArrayList<>(routes.values()); return new ArrayList<>(routes.values());
} }
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
int n = u8(raw, c++); int n = u8(raw, c++);
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8); String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n; 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) { } else if (blockType == 50) {
int sessionsMode = u8(raw, c++); int sessionsMode = u8(raw, c++);
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget; import blockchain.body.BodyHasTarget;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody; import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody; import blockchain.body.UserParamBody;
import org.slf4j.Logger; 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.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_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; 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.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.sync.AddBlockSyncService; import server.sync.AddBlockSyncService;
@@ -166,6 +168,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_name_already_exists" -> "Такое название канала уже занято"; case "channel_name_already_exists" -> "Такое название канала уже занято";
case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; 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 "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
@@ -277,6 +282,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
} }
ChannelNameStateEntry channelNameStateEntry = null; ChannelNameStateEntry channelNameStateEntry = null;
ChannelNameStateEntry channelMetaUpdateEntry = null;
Chat200CreateSeed chat200CreateSeed = null; Chat200CreateSeed chat200CreateSeed = null;
if (block.body instanceof CreateChannelBody createChannelBody) { if (block.body instanceof CreateChannelBody createChannelBody) {
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); 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); 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 = new ChannelNameStateEntry();
channelNameStateEntry.setSlug(slug); channelNameStateEntry.setSlug(slug);
channelNameStateEntry.setDisplayName(normalizedName); channelNameStateEntry.setDisplayName(displayName);
channelNameStateEntry.setChannelDescription( channelNameStateEntry.setChannelDescription(channelDescription);
createChannelBody.channelDescription == null channelNameStateEntry.setAvaAr(avaAr);
? "" channelNameStateEntry.setAvaSha256(avaSha256);
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription) channelNameStateEntry.setAvaSize(avaSize);
); channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
channelNameStateEntry.setOwnerLogin(login); channelNameStateEntry.setOwnerLogin(login);
channelNameStateEntry.setOwnerBlockchainName(blockchainName); channelNameStateEntry.setOwnerBlockchainName(blockchainName);
channelNameStateEntry.setChannelTypeCode(channelTypeCode); channelNameStateEntry.setChannelTypeCode(channelTypeCode);
@@ -329,6 +357,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription(); chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L; 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 // 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) { if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed); upsertChat200StateFromCreate(chat200CreateSeed);
@@ -544,6 +632,86 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
long updatedAtMs; 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 { private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
try (Connection c = shine.db.DbController.getInstance().getConnection(); try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(""" PreparedStatement ps = c.prepareStatement("""
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
BlockchainStateEntry st, BlockchainStateEntry st,
BlockEntry be, BlockEntry be,
UserParamEntry userParamEntry, UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry) throws SQLException { ChannelNameStateEntry channelNameStateEntry,
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
long nowMs = System.currentTimeMillis(); long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes(); byte[] blockBytes = block.toBytes();
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
channelNameStateDAO.insert(c, channelNameStateEntry); channelNameStateDAO.insert(c, channelNameStateEntry);
} }
if (channelMetaUpdateEntry != null) {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
c.commit(); c.commit();
committed = true; committed = true;
} catch (Exception e) { } 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; package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry; import blockchain.BchBlockEntry;
import blockchain.MsgSubType;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.TextLineBody;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import shine.db.DbController; import shine.db.DbController;
@@ -20,6 +22,7 @@ import java.util.Map;
public final class ChannelNamesStateBootstrapper { public final class ChannelNamesStateBootstrapper {
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class); private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
private static final int MSG_TYPE_TECH = 0; 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 final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
private static volatile boolean bootstrapped; private static volatile boolean bootstrapped;
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
continue; continue;
} }
final String displayName; String displayName;
final String slug; final String slug;
final String channelDescription; String channelDescription;
final int channelTypeCode; final int channelTypeCode;
final int channelTypeVersion; final int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
try { try {
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName); displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
slug = ChannelNameRules.toCanonicalSlug(displayName); slug = ChannelNameRules.toCanonicalSlug(displayName);
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription); channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); 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) { } catch (Exception badName) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)"); skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
continue; continue;
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
entry.setSlug(slug); entry.setSlug(slug);
entry.setDisplayName(displayName); entry.setDisplayName(displayName);
entry.setChannelDescription(channelDescription == null ? "" : channelDescription); entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
entry.setAvaAr(avaAr);
entry.setAvaSha256(avaSha256);
entry.setAvaSize(avaSize);
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
entry.setOwnerLogin(ownerLogin); entry.setOwnerLogin(ownerLogin);
entry.setOwnerBlockchainName(ownerBch); entry.setOwnerBlockchainName(ownerBch);
entry.setChannelTypeCode(channelTypeCode); entry.setChannelTypeCode(channelTypeCode);
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
dao.clearAll(c); dao.clearAll(c);
dao.insertAll(c, entries); dao.insertAll(c, entries);
applyMetaUpdates(c, dao, entries, skipped);
c.commit(); c.commit();
log.info("channel_names_state bootstrapped: {}", entries.size()); log.info("channel_names_state bootstrapped: {}", entries.size());
if (!conflicts.isEmpty()) { if (!conflicts.isEmpty()) {
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
throw new RuntimeException("Failed to bootstrap channel_names_state", e); 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_TEXT = 1;
static final int MSG_TYPE_REACTION = 2; static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0; 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 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_ADD = "add";
static final String COMMAND_REMOVE = "remove"; static final String COMMAND_REMOVE = "remove";
@@ -47,11 +46,40 @@ final class ChannelsReadSupport {
if (rootNumber == 0) { if (rootNumber == 0) {
meta.channelName = STORIES_CHANNEL_NAME; 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; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
return meta; 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"; String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch); ps.setString(1, ownerBch);
@@ -68,21 +96,29 @@ final class ChannelsReadSupport {
BodyRecord body = e.body; BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) { if (body instanceof CreateChannelBody ccb) {
meta.channelName = ccb.channelName; meta.channelName = ccb.channelName;
meta.displayName = ccb.channelName;
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription; meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode); meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion); 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 { } else {
meta.channelName = null; meta.channelName = null;
meta.displayName = null;
meta.channelDescription = ""; meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
} }
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (updatedDescription != null) {
meta.channelDescription = updatedDescription;
}
return meta; return meta;
} catch (Exception ignored) { } catch (Exception ignored) {
meta.channelName = null; meta.channelName = null;
meta.displayName = null;
meta.channelDescription = ""; meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
return meta; return meta;
@@ -91,13 +127,17 @@ final class ChannelsReadSupport {
} }
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException { 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)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch); ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST); 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()) { try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0; 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 { static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """ 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 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 ORDER BY block_number DESC
LIMIT 1 LIMIT 1
"""; """;
@@ -117,7 +157,11 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST); 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()) { try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null; if (!rs.next()) return null;
PostBlock pb = new PostBlock(); PostBlock pb = new PostBlock();
@@ -133,7 +177,7 @@ final class ChannelsReadSupport {
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException { static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
String sql = """ 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 FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=? AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
@@ -169,6 +213,10 @@ final class ChannelsReadSupport {
ti.text = tlb.message; ti.text = tlb.message;
} else if (e.body instanceof TextReplyBody trb) { } else if (e.body instanceof TextReplyBody trb) {
ti.text = trb.message; 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) { } else if (e.body instanceof TextBody tb) {
ti.text = tb.message; ti.text = tb.message;
} }
@@ -183,7 +231,7 @@ final class ChannelsReadSupport {
String sql = """ 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 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 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 BY block_number
""" + order + " LIMIT ?"; """ + order + " LIMIT ?";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -191,8 +239,12 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST); ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode); ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, limit); 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()) { try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>(); List<PostBlock> out = new ArrayList<>();
while (rs.next()) { while (rs.next()) {
@@ -257,21 +309,47 @@ final class ChannelsReadSupport {
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException { 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"; 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)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch); ps.setString(1, bch);
ps.setInt(2, blockNumber); ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash); ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new int[] {0, 0}; if (rs.next()) {
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")}; 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 { static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) { if (rootNumber == 0) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0); return "";
return fromCommand == null ? "" : fromCommand;
} }
// Preferred source: persisted state (fast path, works for CreateChannelBody v2). // Preferred source: persisted state (fast path, works for CreateChannelBody v2).
@@ -287,8 +365,7 @@ final class ChannelsReadSupport {
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) { if (rs.next()) {
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description")); String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber); return saved;
return fromCommand == null ? saved : fromCommand;
} }
} }
} catch (SQLException ignored) { } catch (SQLException ignored) {
@@ -306,8 +383,6 @@ final class ChannelsReadSupport {
BchBlockEntry e = new BchBlockEntry(bytes); BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body; BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) { if (body instanceof CreateChannelBody ccb) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (fromCommand != null) return fromCommand;
return ccb.channelDescription == null ? "" : ccb.channelDescription; return ccb.channelDescription == null ? "" : ccb.channelDescription;
} }
return ""; return "";
@@ -363,45 +438,97 @@ final class ChannelsReadSupport {
return meta.channelTypeVersion; return meta.channelTypeVersion;
} }
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException { static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """ List<MetaEvent> out = new ArrayList<>();
SELECT block_bytes 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 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=? AND block_number=?
ORDER BY block_number DESC LIMIT 1
LIMIT 300 """;
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)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch); ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
ps.setInt(4, MsgSubType.TEXT_REPOST); ps.setInt(4, lineCode);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes")); byte[] bytes = rs.getBytes("block_bytes");
CommandInfo commandInfo = parseCommandText(info.text); TextInfo info = parseTextAndTime(bytes);
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) { try {
return commandInfo.arg; 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; return out;
}
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);
} }
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException { static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
@@ -506,6 +633,22 @@ final class ChannelsReadSupport {
return sb.toString(); 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 { static final class PostBlock {
String login; String login;
String bchName; String bchName;
@@ -526,19 +669,29 @@ final class ChannelsReadSupport {
static final class ChannelMeta { static final class ChannelMeta {
String channelName; String channelName;
String displayName;
String channelDescription; String channelDescription;
int channelTypeCode; int channelTypeCode;
int channelTypeVersion; int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
} }
static final class CommandInfo { static final class MetaEvent {
final String command; String kind;
final String arg; String login;
String bchName;
CommandInfo(String command, String arg) { int blockNumber;
this.command = command; byte[] blockHash;
this.arg = arg; Integer lineStep;
} long createdAtMs;
String title;
String description;
String avaAr;
String avaSha256;
long avaSize;
} }
static final class PairChannelSelector { static final class PairChannelSelector {
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode); ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
channel.setChannelName(meta.channelName); channel.setChannelName(meta.channelName);
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
channel.setChannelDescription(meta.channelDescription); 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.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion); channel.setChannelTypeVersion(meta.channelTypeVersion);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setChannelRoot(rootRef); channel.setChannelRoot(rootRef);
resp.setChannel(channel); 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<>( List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc) ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
); );
@@ -117,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
v1.setCreatedAtMs(postText.createdAtMs); v1.setCreatedAtMs(postText.createdAtMs);
versionsOut.add(v1); 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); List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) { for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); 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); int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]); item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]); item.setRepliesCount(stats[1]);
item.setRatingsCount(stats[2]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash)); item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item); items.add(item);
@@ -17,6 +17,7 @@ import shine.db.DbController;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.util.Comparator;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.List; 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 { 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(); 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<>(); List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
for (PostRow row : replies) { for (PostRow row : replies) {
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree(); Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
return out; 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 = """ 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 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 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=? AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
LIMIT ?
"""; """;
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MsgSubType.TEXT_REPLY); ps.setInt(1, MsgSubType.TEXT_REPLY);
ps.setString(2, toBchName); ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setInt(3, toBlockNumber); ps.setString(3, toBchName);
ps.setBytes(4, toBlockHash); ps.setInt(4, toBlockNumber);
ps.setInt(5, limit); ps.setBytes(5, toBlockHash);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>(); List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs)); 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; return out;
} }
} }
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
first.setCreatedAtMs(base.createdAtMs); first.setCreatedAtMs(base.createdAtMs);
versions.add(first); versions.add(first);
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) { if (row.msgSubType == MsgSubType.TEXT_REPLY
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST; || 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)) { for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem(); 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); int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]); node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]); node.setRepliesCount(stats[1]);
node.setRatingsCount(stats[2]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash)); node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) { if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo(); Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
@@ -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.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch); channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(meta.channelName); channelRef.setChannelName(meta.channelName);
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
channelRef.setChannelDescription(meta.channelDescription); 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.setChannelTypeCode(meta.channelTypeCode);
channelRef.setChannelTypeVersion(meta.channelTypeVersion); channelRef.setChannelTypeVersion(meta.channelTypeVersion);
channelRef.setPersonal(meta.channelTypeCode == 100); channelRef.setPersonal(meta.channelTypeCode == 100);
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
AND block_number = ? AND block_number = ?
AND block_hash = ? AND block_hash = ?
AND msg_type = ? AND msg_type = ?
AND msg_sub_type = ? AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
%s %s
LIMIT 1 LIMIT 1
""".formatted(strictChannelMatch ? "AND line_code = ?" : ""); """.formatted(strictChannelMatch ? "AND line_code = ?" : "");
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex)); existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT); existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
existsPs.setInt(5, MsgSubType.TEXT_POST); 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) { if (strictChannelMatch) {
existsPs.setInt(6, expectedRoot); existsPs.setInt(11, expectedRoot);
} }
boolean exists; boolean exists;
@@ -8,6 +8,7 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response { public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel; private Channel channel;
private List<MessageItem> messages = new ArrayList<>(); private List<MessageItem> messages = new ArrayList<>();
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
public Channel getChannel() { return channel; } public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; } public void setChannel(Channel channel) { this.channel = channel; }
@@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public List<MessageItem> getMessages() { return messages; } public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; } public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public List<ChannelMetaEvent> getMetaEvents() { return metaEvents; }
public void setMetaEvents(List<ChannelMetaEvent> metaEvents) { this.metaEvents = metaEvents; }
public static class Channel { public static class Channel {
private String ownerLogin; private String ownerLogin;
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String displayName;
private String channelDescription; private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode; private Integer channelTypeCode;
private Integer channelTypeVersion; private Integer channelTypeVersion;
private BlockRef channelRoot; private BlockRef channelRoot;
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelName() { return channelName; } public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = 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 String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = 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 Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = 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 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 { public static class MessageItem {
private BlockRef messageRef; private BlockRef messageRef;
private Integer msgSubType; private Integer msgSubType;
@@ -56,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String targetBlockchainName; private String targetBlockchainName;
private Integer targetBlockNumber; private Integer targetBlockNumber;
private String targetBlockHash; private String targetBlockHash;
private Integer targetMsgSubType;
private String targetText;
private String targetAuthorLogin;
private String targetAuthorBlockchainName;
private Long targetCreatedAtMs;
private long createdAtMs; private long createdAtMs;
private String text; private String text;
private int likesCount; private int likesCount;
private boolean likedByMe; private boolean likedByMe;
private int repliesCount; private int repliesCount;
private int ratingsCount;
private int versionsTotal; private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>(); private List<VersionItem> versions = new ArrayList<>();
@@ -87,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getTargetBlockHash() { return targetBlockHash; } public String getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = 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 long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = 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 int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = 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 int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = 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 ownerLogin;
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String displayName;
private String channelDescription; private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode; private Integer channelTypeCode;
private Integer channelTypeVersion; private Integer channelTypeVersion;
private boolean personal; private boolean personal;
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelName() { return channelName; } public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = 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 String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = 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 Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; } public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
@@ -26,6 +26,7 @@ import java.util.Set;
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_INVITE = 100; private static final int TYPE_INVITE = 100;
private static final int TYPE_CONNECT_START = 170;
private static final long PUSH_CALL_TTL_MS = 10_000L; private static final long PUSH_CALL_TTL_MS = 10_000L;
@Override @Override
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim(); String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
String callId = req.getCallId() == null ? "" : req.getCallId().trim(); String callId = req.getCallId() == null ? "" : req.getCallId().trim();
int type = req.getType() == null ? TYPE_INVITE : req.getType(); int type = req.getType() == null ? TYPE_INVITE : req.getType();
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) { String data = req.getData() == null ? "" : req.getData().trim();
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); 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); CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
payload.put("fromSessionId", ctx.getSessionId()); payload.put("fromSessionId", ctx.getSessionId());
payload.put("toLogin", to); payload.put("toLogin", to);
payload.put("callId", callId); payload.put("callId", callId);
payload.put("type", TYPE_INVITE); payload.put("type", type);
payload.put("timeMs", timeMs); payload.put("timeMs", timeMs);
if (!data.isBlank()) {
payload.put("data", data);
}
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload); boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
if (sent) wsDelivered++; 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) { for (ActiveSessionEntry session : allTargetSessions) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim(); String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) { 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.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; 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.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.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.CurrentUsersDAO; import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry; import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.CurrentUserEntry; 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_SINGLE = "single_session";
private static final String TARGET_MODE_ALL = "all_sessions"; private static final String TARGET_MODE_ALL = "all_sessions";
private static final long ALLOWED_SKEW_MS = 30_000L; 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 @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { 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"); 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()) { if (!clientSignatureB64.isBlank()) {
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) { 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); 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 code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала"; String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
return NetExceptionResponseFactory.error(req, 404, code, msg); 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", "Не удалось доставить сигнал ни в одну целевую сессию"); 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(); Net_SendSignal_Response resp = new Net_SendSignal_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setDeliveredCount(deliveredSessionIds.size()); resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
resp.setDeliveredSessionIds(deliveredSessionIds); resp.setDeliveredSessionIds(deliveredSessionIds);
resp.setDeliveredWsSessions(deliveredSessionIds.size());
resp.setDeliveredFcmSessions(webPushDelivered);
resp.setDeliveredWebPushSessions(webPushDelivered);
return resp; return resp;
} }
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
+ dataSha256B64; + 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 { private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName); byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
return targets; 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) { private static String safe(String value) {
return value == null ? "" : value.trim(); 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 toLogin;
private String callId; private String callId;
private Integer type; private Integer type;
private String data;
public String getToLogin() { return toLogin; } public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = 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 Integer getType() { return type; }
public void setType(Integer type) { this.type = 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 { public class Net_SendSignal_Response extends Net_Response {
private int deliveredCount; private int deliveredCount;
private List<String> deliveredSessionIds = new ArrayList<>(); private List<String> deliveredSessionIds = new ArrayList<>();
private int deliveredWsSessions;
private int deliveredFcmSessions;
private int deliveredWebPushSessions;
public int getDeliveredCount() { public int getDeliveredCount() {
return deliveredCount; return deliveredCount;
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
public void setDeliveredSessionIds(List<String> deliveredSessionIds) { public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
this.deliveredSessionIds = 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, s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE AND s.is_server = TRUE
@@ -1100,12 +1104,16 @@ public final class PostgresStorageRepository
FOR affected_user IN FOR affected_user IN
SELECT u.login SELECT u.login
FROM solana_user_pda_current u FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text( CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb ELSE u.access_servers_json::jsonb
END END
) AS access_server(login_value) ) 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) WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); 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-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. - `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`. - `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
### dao_запуск ### 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 client.version=1.5.35
server.version=1.3.1 server.version=1.4.11
+3 -3
View File
@@ -1,3 +1,3 @@
backup.schema.version=1 backup.schema.version=2
backup.full.version=2 backup.full.version=3
last.full.backup.date=2026-07-10 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 Filesystem Size Used Avail Use% Mounted on
tmpfs 392M 1.2M 391M 1% /run tmpfs 197M 1.2M 196M 1% /run
/dev/vda2 32G 14G 17G 46% / /dev/sda1 40G 6.7G 31G 18% /
tmpfs 2.0G 0 2.0G 0% /dev/shm tmpfs 982M 0 982M 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock 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 NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00 sda
sr1 └─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
vda sr0
├─vda1
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
@@ -1,13 +1,48 @@
UNIT FILE STATE PRESET UNIT FILE STATE VENDOR PRESET
agent-memory.service enabled enabled apparmor.service enabled enabled
blk-availability.service enabled enabled
caddy.service enabled enabled caddy.service enabled enabled
console-setup.service enabled enabled
containerd.service enabled enabled containerd.service enabled enabled
coturn.service enabled enabled coturn.service enabled enabled
cron.service enabled enabled
dmesg.service enabled enabled
docker.service enabled enabled docker.service enabled enabled
elaira-agent.service enabled enabled e2scrub_reap.service enabled enabled
hermes-dashboard.service enabled enabled finalrd.service enabled enabled
hermes-gateway.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 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 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 UNIT LOAD ACTIVE SUB DESCRIPTION
agent-memory.service loaded active running agent-memory service
caddy.service loaded active running Caddy caddy.service loaded active running Caddy
containerd.service loaded active running containerd container runtime containerd.service loaded active running containerd container runtime
coturn.service loaded active running coTURN STUN/TURN Server coturn.service loaded active running coTURN STUN/TURN Server
cron.service loaded active running Regular background program processing daemon cron.service loaded active running Regular background program processing daemon
dbus.service loaded active running D-Bus System Message Bus dbus.service loaded active running D-Bus System Message Bus
docker.service loaded active running Docker Application Container Engine 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 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 ModemManager.service loaded active running Modem Manager
multipathd.service loaded active running Device-Mapper Multipath Device Controller 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 polkit.service loaded active running Authorization Manager
qemu-guest-agent.service loaded active running QEMU Guest Agent qemu-guest-agent.service loaded active running QEMU Guest Agent
rsyslog.service loaded active running System Logging Service 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 ssh.service loaded active running OpenBSD Secure Shell server
systemd-hostnamed.service loaded active running Hostname Service systemd-hostnamed.service loaded active running Hostname Service
systemd-journald.service loaded active running Journal Service systemd-journald.service loaded active running Journal Service
@@ -26,10 +26,9 @@
udisks2.service loaded active running Disk Manager udisks2.service loaded active running Disk Manager
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
upower.service loaded active running Daemon for power management upower.service loaded active running Daemon for power management
user@1002.service loaded active running User Manager for UID 1002 user@1000.service loaded active running User Manager for UID 1000
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.
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. 28 loaded units listed.
@@ -1,32 +1,12 @@
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess 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 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37)) LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15)) LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34)) LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13)) LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58)) LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21)) LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60)) LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23)) LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4))
LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6)) LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7))
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14)) LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19))
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))
@@ -1,2 +1,2 @@
NAMES IMAGE STATUS PORTS 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 shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp
@@ -1,8 +1 @@
4.0K /home/player/hosts.codex.test 85M /home/player/SHiNE
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
@@ -2,11 +2,10 @@
4.0K /var/local 4.0K /var/local
4.0K /var/mail 4.0K /var/mail
4.0K /var/opt 4.0K /var/opt
4.0K /var/snap
16K /var/spool 16K /var/spool
88K /var/tmp 72K /var/tmp
3.2M /var/backups 1.9M /var/backups
143M /var/cache 319M /var/cache
1.2G /var/lib 1.1G /var/log
1.5G /var/log 1.5G /var/lib
2.8G /var 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 auto_https disable_redirects
root * /home/player/sites/OpenMindSoft.io
try_files {path} /index.html
file_server
} }
shineup.me { 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] [Unit]
Description=SHiNE Server Description=SHiNE Server (shineup.me)
After=network.target After=network.target
[Service] [Service]
@@ -7,7 +7,7 @@ Type=simple
User=player User=player
Group=player Group=player
WorkingDirectory=/home/player/SHiNE/shine-server 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 Restart=always
RestartSec=3 RestartSec=3
@@ -1,709 +1,17 @@
# Coturn TURN SERVER configuration file listening-port=3478
# fingerprint
# Boolean values note: where boolean value is supposed to be used, lt-cred-mech
# 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.
#
use-auth-secret use-auth-secret
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
# 'Static' authentication secret value (a string) for TURN REST API only. realm=turn1.shineup.me
# If not set, then the turn server total-quota=200
# will try to use the 'dynamic' value in turn_secret table stale-nonce=600
# in user database (if present). The database-stored value can be changed on-the-fly no-multicast-peers
# by a separate program, so this is why that other mode is 'dynamic'. no-loopback-peers
# no-cli
#static-auth-secret=north simple-log
external-ip=178.208.64.62
# Server name used for listening-ip=0.0.0.0
# the oAuth authentication purposes. relay-ip=178.208.64.62
# The default value is the realm name. min-port=49000
# max-port=53999
#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>
@@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CAP_DIR="${SCHEME_DIR}/captures" CAP_DIR="${SCHEME_DIR}/captures"
CFG_DIR="${SCHEME_DIR}/configs" CFG_DIR="${SCHEME_DIR}/configs"
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd" 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] Обновляю инвентарь" 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}" '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" 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" ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
echo "[2/3] Обновляю ключевые конфиги" echo "[2/3] Обновляю ключевые конфиги"
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile" if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf" rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/" else
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/" 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] Метка обновления" echo "[3/3] Метка обновления"
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt" 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_SERVER_ADDRESS="server2.shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \ DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \ DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \
TARGET_URL="https://server2.shineup.me" \ TARGET_URL="https://server2.shineup.me" \
REQUIRE_BACKUP="1" \ REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh" bash "$(dirname "$0")/deploy_ui.sh"
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="shineupme" \
DEPLOY_SERVER_ADDRESS="shineup.me" \ DEPLOY_SERVER_ADDRESS="shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \ DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \ DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \
TARGET_URL="https://shineup.me" \ TARGET_URL="https://shineup.me" \
REQUIRE_BACKUP="1" \ REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh" bash "$(dirname "$0")/deploy_ui.sh"
+76 -5
View File
@@ -88,6 +88,10 @@
- `limit_exceeded` - `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync - `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации - `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` - `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock` ## 5. Какие блоки реально можно добавлять через `AddBlock`
@@ -103,7 +107,13 @@
- `TEXT_EDIT_POST (11)` - `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)` - `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)` - `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)** 3. **REACTION (type=2)**
- `REACTION_LIKE (1)` - `REACTION_LIKE (1)`
@@ -133,7 +143,68 @@
5. **USER_PARAM (type=4)** 5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)` - `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 списка подписок с серверными счётчиками непрочитанного; - нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC. - нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков ## 8. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`. 1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`: 2. При `bad_prev_hash` или `bad_block_number`:
@@ -157,7 +228,7 @@
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост. 4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 8. USER_PARAM для «личных данных» ## 9. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**: Да, на текущем API это можно добавить **без изменения серверного кода**:
@@ -183,7 +254,7 @@
--- ---
## 9. `GetBlockchainBlock` ## 10. `GetBlockchainBlock`
### Назначение ### Назначение
+18 -1
View File
@@ -297,6 +297,7 @@
Первый целевой сценарий: Первый целевой сценарий:
- `remote AddBlock via homeserver session` - `remote AddBlock via homeserver session`
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
То есть телефон без локального `blockchain.key` может: То есть телефон без локального `blockchain.key` может:
@@ -336,6 +337,12 @@
- запрос должен быть подписан и `session key`, и `client key`; - запрос должен быть подписан и `session key`, и `client key`;
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой. - в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
Для звонковых сигналов `signalType = call_*` правило строже:
- `clientSignatureB64` обязателен;
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
- это правило действует и для `single_session`, и для `all_sessions`.
### Запрос в одну сессию ### Запрос в одну сессию
```json ```json
@@ -366,11 +373,20 @@
"ok": true, "ok": true,
"payload": { "payload": {
"deliveredCount": 1, "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 ```json
@@ -428,6 +444,7 @@
- `404 / USER_NOT_FOUND` — логин адресата не найден. - `404 / USER_NOT_FOUND` — логин адресата не найден.
- `400 / BAD_DATA` — сервер не смог обработать `data`. - `400 / BAD_DATA` — сервер не смог обработать `data`.
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`. - `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`. - `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн. - `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий. - `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
+76 -3
View File
@@ -14,11 +14,13 @@
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки. предки, фокус-сообщение, потомки.
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. 4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
5. `ListGroupChats200` — отдает список групповых чатов типа `200`. 5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. 6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
@@ -53,6 +55,12 @@
"ownerLogin": "Alice", "ownerLogin": "Alice",
"ownerBlockchainName": "alice-001", "ownerBlockchainName": "alice-001",
"channelName": "0", "channelName": "0",
"displayName": "Мой канал",
"channelDescription": "Короткое описание",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"personal": true, "personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." } "channelRoot": { "blockNumber": 0, "blockHash": "..." }
}, },
@@ -141,8 +149,42 @@
"ownerLogin": "Bob", "ownerLogin": "Bob",
"ownerBlockchainName": "bob-001", "ownerBlockchainName": "bob-001",
"channelName": "news", "channelName": "news",
"displayName": "Новости Bob",
"channelDescription": "Канал о новостях",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"channelRoot": { "blockNumber": 123, "blockHash": "..." } "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": [ "messages": [
{ {
"messageRef": { "blockNumber": 140, "blockHash": "..." }, "messageRef": { "blockNumber": 140, "blockHash": "..." },
@@ -152,6 +194,7 @@
"text": "текущая версия", "text": "текущая версия",
"likesCount": 12, "likesCount": 12,
"repliesCount": 3, "repliesCount": 3,
"ratingsCount": 2,
"versionsTotal": 4, "versionsTotal": 4,
"versions": [ "versions": [
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 }, { "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
@@ -208,6 +251,36 @@
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64. - `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`. - Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ). - В `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` | лента каналов/подписок | | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
| `GetMessageThread` | `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` | счетчики разделов каналов | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
@@ -11,14 +11,16 @@
- `12_REACTION_Blocks.md` — реакции (`type=2`). - `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`). - `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`). - `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`).
## Быстрая карта типов ## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL. - `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=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя. - `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 байта); 4. `channelType` (`uint16`, 2 байта);
5. `channelTypeVersion` (`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. Типы каналов ## 2. Типы каналов
- `0``stories` (root-канал пользователя). - `0``stories` (root-канал пользователя).
- `1` — публичный канал. - `1` — публичный канал.
+10 -24
View File
@@ -1,33 +1,19 @@
# Командные сообщения каналов # Командные сообщения каналов
## 1. Общий префикс Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала.
Командные сообщения распознаются по префиксу:
`/.` ## Удалённая команда `/.desc`
Пример: Команда `/.desc <text>` больше не поддерживается и не применяется сервером.
- `/.desc Новый комментарий канала`
## 2. Поддерживаемые команды Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
### Для всех типов каналов (`0`, `1`, `100`, `200`) - `msg_type=1`
- `/.desc <text>` — смена описания канала. - `subType=90`
- `TEXT_CHANNEL_META`
Примечание: Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
### Дополнительно для `type=200` ## Зарезервированные команды type=200
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
Формат аргументов фиксирован: через пробел. Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
+78 -2
View File
@@ -1,6 +1,6 @@
# TEXT блоки (`type=1`, `version=1`) # TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования. TEXT-тип хранит сообщения, материалы и редактирования.
## Подтипы ## Подтипы
@@ -21,18 +21,94 @@ TEXT-тип хранит сообщения и редактирования.
- target на исходный REPLY + новый текст. - target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока). - допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30``TEXT_REPOST` 5. `subType=30``TEXT_RATING`
- target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала.
6. `subType=50``TEXT_REPOST`
- репост сообщения в линию канала; - репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария; - содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются); - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов. - временно отключён для записи через `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
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий 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
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. - Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения. - Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. - Для удаления используется именно 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 ## 2026-07-14 20:43:43 +0400
- Базовый коммит-ориентир: `118be41`. - Базовый коммит-ориентир: `118be41`.
- В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта: - В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта:
@@ -68,6 +150,27 @@
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации. - В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`. - В `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 ## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`. - Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`: - Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
+9 -3
View File
@@ -17,11 +17,17 @@
Социальные связи (`msg_type=3`). Социальные связи (`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`). Параметры пользователя (`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`. Типы каналов и формат `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"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <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="/" /> <base href="/" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" /> <link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260715214500'; window.__SHINE_BUILD_HASH__ = '20260806223040';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
@@ -26,8 +29,20 @@ window.__SHINE_BUILD_HASH__ = '20260715214500';
</script> </script>
</head> </head>
<body> <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 class="app-shell">
<div id="topbar-slot" class="topbar-slot" hidden></div>
<main id="app-screen" class="screen-content"></main> <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 id="toolbar-slot" class="toolbar-slot"></div>
</div> </div>
<div id="modal-root"></div> <div id="modal-root"></div>
+162 -4
View File
@@ -10,6 +10,7 @@ import {
handleIncomingCallInvite, handleIncomingCallInvite,
handleIncomingCallPush, handleIncomingCallPush,
handleIncomingCallSignal, handleIncomingCallSignal,
handleIncomingCallSignalViaSendSignal,
handleStopCallPush, handleStopCallPush,
setCallDebugReporter, setCallDebugReporter,
startDebugConnectionAsInitiator, startDebugConnectionAsInitiator,
@@ -59,6 +60,7 @@ import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js'; import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; 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 remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435'; import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
@@ -120,6 +122,7 @@ const routes = {
'access-servers-view': accessServersView, 'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView, 'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView, 'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView, 'device-view': deviceView,
'connect-device-view': connectDeviceView, 'connect-device-view': connectDeviceView,
@@ -148,8 +151,11 @@ const routes = {
}; };
const screenEl = document.getElementById('app-screen'); 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 toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell'); const appShellEl = document.querySelector('.app-shell');
const initialSplashEl = document.getElementById('initial-splash');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000; const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
@@ -175,6 +181,9 @@ let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null; let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null; let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false; 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 CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([ const GUEST_ALLOWED_PAGES = new Set([
'start-view', 'start-view',
@@ -198,6 +207,90 @@ initPwaInstallPromptHandling();
initCallUiOverlay(); initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload)); 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() { async function unlockHiddenDmAudio() {
try { try {
const Ctx = window.AudioContext || window.webkitAudioContext; 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) { async function notifyHiddenIncomingMessage(fromLogin, text) {
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`; const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
const title = `Сообщение от ${fromLogin}`; const title = `Сообщение от ${fromLogin}`;
@@ -329,6 +439,11 @@ function refreshConnectionUi() {
const bannerEl = ensureConnectionRetryBannerEl(); const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return; if (!bannerEl) return;
if (!initialConnectionCompleted) {
bannerEl.hidden = true;
return;
}
if (state === 'connected' || state === 'updating') { if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true; bannerEl.hidden = true;
stopConnectionCountdown(); stopConnectionCountdown();
@@ -358,6 +473,21 @@ function refreshConnectionUi() {
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора'; 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() { function startConnectionCountdown() {
if (connectionStatusCountdownId) return; if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => { connectionStatusCountdownId = window.setInterval(() => {
@@ -465,6 +595,9 @@ function setConnectionStatus(nextState, text = '') {
if (!state) return; if (!state) return;
connectionState = state; connectionState = state;
connectionStatusText = String(text || '').trim(); connectionStatusText = String(text || '').trim();
if (state === 'connected') {
finishInitialConnectionSplash();
}
if (state === 'disconnected') { if (state === 'disconnected') {
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) { if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
@@ -862,7 +995,14 @@ function renderPageFailureFallback(pageId, error) {
screenEl.append(wrap); screenEl.append(wrap);
screenEl.classList.toggle('no-app-chrome', false); screenEl.classList.toggle('no-app-chrome', false);
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
toolbarEl.innerHTML = ''; toolbarEl.innerHTML = '';
toolbarHeightObserver?.sync?.();
refreshConnectionUi(); refreshConnectionUi();
} }
@@ -886,10 +1026,17 @@ function renderApp() {
currentCleanup(); currentCleanup();
currentCleanup = null; currentCleanup = null;
} }
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
try { try {
screenEl.innerHTML = ''; 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)) { if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node'); throw new Error('Page render returned invalid node');
} }
@@ -897,7 +1044,6 @@ function renderApp() {
screenEl.append(screen); screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
@@ -905,6 +1051,7 @@ function renderApp() {
if (showAppChrome) { if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
} }
toolbarHeightObserver?.sync?.();
refreshConnectionUi(); refreshConnectionUi();
} catch (error) { } catch (error) {
console.error('[renderApp] controlled fallback', error); console.error('[renderApp] controlled fallback', error);
@@ -922,6 +1069,7 @@ function refreshToolbarOnly() {
if (showAppChrome) { if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
} }
toolbarHeightObserver?.sync?.();
refreshConnectionUi(); refreshConnectionUi();
} }
@@ -1000,6 +1148,7 @@ async function ensureSessionRuntimeStarted() {
async function init() { async function init() {
consumeCallPushActionFromUrlIfAny(); consumeCallPushActionFromUrlIfAny();
void tryLockPortraitOrientation();
if (state.session.isLocalDemo) { if (state.session.isLocalDemo) {
setConnectionStatus('connected'); setConnectionStatus('connected');
@@ -1183,13 +1332,14 @@ async function init() {
if (added && isIncomingForCurrent && !payload.backlog) { if (added && isIncomingForCurrent && !payload.backlog) {
const parsedText = parseDmTechBlocks(text); const parsedText = parseDmTechBlocks(text);
const displayText = String(parsedText.displayText || ''); const displayText = String(parsedText.displayText || '');
const suppressHiddenNotification = parsedText.callSummary?.status === 'completed';
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false }); void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') { } else if (Notification.permission === 'granted' && !suppressHiddenNotification) {
try { try {
void notifyHiddenIncomingMessage(fromLogin, displayText || ''); void notifyHiddenIncomingMessage(fromLogin, displayText || '');
} catch {} } catch {}
} else { } else if (!suppressHiddenNotification) {
void playDmSignal({ extended: true }); void playDmSignal({ extended: true });
} }
} }
@@ -1286,6 +1436,10 @@ async function init() {
try { await handleIncomingCallSignal(evt); } catch {} try { await handleIncomingCallSignal(evt); } catch {}
}); });
authService.onEvent('IncomingSignal', async (evt) => {
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
});
authService.onEvent('DebugConnectPrepareResponder', async (evt) => { authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
try { try {
const p = evt?.payload || {}; const p = evt?.payload || {};
@@ -1378,8 +1532,12 @@ async function init() {
}, { passive: true }); }, { passive: true });
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;
void tryLockPortraitOrientation();
void checkConnectionHealth(); void checkConnectionHealth();
}); });
window.addEventListener('orientationchange', () => {
void tryLockPortraitOrientation();
});
} }
init(); init();
File diff suppressed because it is too large Load Diff
+22 -602
View File
@@ -1,56 +1,7 @@
import { authService } from '../state.js'; import { openArweaveAttachmentManager } from './arweave-attachment-manager.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 { saveProfileAvatarArweave } from '../services/user-profile-params.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024; export async function openAvatarWizard({
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({
login, login,
storagePwd, storagePwd,
gateway, gateway,
@@ -58,568 +9,37 @@ export function openAvatarWizard({
onAvatarSaved, onAvatarSaved,
onStatus, onStatus,
} = {}) { } = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || '').trim(); const cleanGateway = String(gateway || '').trim();
if (!cleanLogin || !cleanStoragePwd) { if (!cleanLogin || !cleanStoragePwd) {
return Promise.reject(new Error('Нет активной сессии.')); throw new Error('Нет активной сессии.');
} }
let closed = false; const item = await openArweaveAttachmentManager({
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, login: cleanLogin,
storagePwd: cleanStoragePwd, storagePwd: cleanStoragePwd,
onStatus: (message) => {
setNodeText(loadingEl, message);
if (typeof onStatus === 'function') onStatus(message);
},
});
balanceInfo = await getArweaveBalance({
gateway: cleanGateway, gateway: cleanGateway,
address: walletCtx.address, navigate,
mode: 'avatar',
historyPurpose: 'avatar',
}); });
} catch (error) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.'); if (!item?.ar || !item?.sha256) return false;
return;
if (typeof onStatus === 'function') {
onStatus('Сохраняем аватар в профиль...');
} }
if (BigInt(balanceInfo.winston) <= 0n) { await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256);
showStepZeroBalance();
return;
}
showStepUploadForm();
};
showStepChoice(); 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 class="toolbar-label-wrap">
<span>${item.label}</span> <span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown"> <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 class="toolbar-connection-dot" aria-hidden="true"></span>
</span> </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'; import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' }; export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
const MAX_ACCESS_SERVERS = 2;
function escapeHtml(value) { function escapeHtml(value) {
return String(value || '') return String(value || '')
@@ -39,8 +40,9 @@ function uniqueLogins(values) {
if (!login || seen.has(login)) return; if (!login || seen.has(login)) return;
seen.add(login); seen.add(login);
result.push(login); result.push(login);
if (result.length >= MAX_ACCESS_SERVERS) return;
}); });
return result; return result.slice(0, MAX_ACCESS_SERVERS);
} }
function shortenSignature(value) { function shortenSignature(value) {
@@ -309,7 +311,7 @@ export function render({ navigate }) {
addTitle.textContent = 'Добавить сервер доступа'; addTitle.textContent = 'Добавить сервер доступа';
const addHint = document.createElement('p'); const addHint = document.createElement('p');
addHint.className = 'meta-muted'; addHint.className = 'meta-muted';
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.'; addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
const addInput = document.createElement('input'); const addInput = document.createElement('input');
addInput.className = 'input'; addInput.className = 'input';
addInput.type = 'text'; addInput.type = 'text';
@@ -329,7 +331,9 @@ export function render({ navigate }) {
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton); addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
const refreshAddButton = () => { const refreshAddButton = () => {
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value)); addButton.disabled = operationBusy
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|| (!selectedCandidate && !normalizeLogin(addInput.value));
}; };
const setOperationBusy = (busy) => { const setOperationBusy = (busy) => {
@@ -348,7 +352,9 @@ export function render({ navigate }) {
addInput.value = candidate.login; addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else { } else {
addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
: 'Для изменения списка понадобится подпись root key.';
} }
}; };
@@ -457,12 +463,17 @@ export function render({ navigate }) {
} }
currentAccessServers = rows; currentAccessServers = rows;
renderServerList(); renderServerList();
refreshAddButton();
listStatus.textContent = rows.length listStatus.textContent = rows.length
? `Найдено серверов доступа: ${rows.length}` ? `Найдено серверов доступа: ${rows.length}`
: 'В PDA пользователя пока нет серверов доступа.'; : 'В PDA пользователя пока нет серверов доступа.';
if (rows.length >= MAX_ACCESS_SERVERS) {
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
}
} catch (error) { } catch (error) {
currentAccessServers = []; currentAccessServers = [];
renderServerList(); renderServerList();
refreshAddButton();
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.'; listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
} }
}; };
@@ -654,6 +665,10 @@ export function render({ navigate }) {
addStatus.textContent = `Сервер @${login} уже есть в списке.`; addStatus.textContent = `Сервер @${login} уже есть в списке.`;
return; return;
} }
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
return;
}
try { try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); 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 { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
channelNameErrorText,
normalizeChannelDescription,
normalizeChannelDisplayName, normalizeChannelDisplayName,
validateChannelDisplayName, validateChannelDisplayName,
} from '../services/channel-name-rules.js'; } 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 CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) { function persistCreateSuccessFlash(message) {
try { try {
@@ -21,13 +28,55 @@ function persistCreateSuccessFlash(message) {
} }
} }
function validateDescription(value) { function normalizeMetaText(value, max, label) {
const normalized = normalizeChannelDescription(value); const normalized = String(value || '').trim();
const bytes = new TextEncoder().encode(normalized).length; const length = Array.from(normalized).length;
if (bytes > 200) { if (length > max) {
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' }; 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 }) { export function render({ navigate }) {
@@ -36,7 +85,7 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Создать канал', title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
}), }),
); );
@@ -44,19 +93,31 @@ export function render({ navigate }) {
const form = document.createElement('form'); const form = document.createElement('form');
form.className = 'card stack'; form.className = 'card stack';
form.innerHTML = ` form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong> <div class="channel-create-avatar-head">
<p class="channel-head-meta">Разрешены только латинские буквы, цифры, _ и -.</p> <div id="channel-avatar-preview"></div>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.</p> <div class="channel-create-avatar-side">
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p> <div class="channel-create-avatar-status-row">
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div> <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 /> <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> <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> <label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea> <textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div> <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-description-error" class="meta-muted inline-error"></div>
<div id="channel-create-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 nameEl = form.querySelector('#channel-name');
const titleEl = form.querySelector('#channel-title');
const descriptionEl = form.querySelector('#channel-description'); 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 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 descriptionErrorEl = form.querySelector('#channel-description-error');
const descriptionCounterEl = form.querySelector('#channel-description-counter'); const descriptionCounterEl = form.querySelector('#channel-description-counter');
const errorEl = form.querySelector('#channel-create-error'); const errorEl = form.querySelector('#channel-create-error');
@@ -76,38 +145,135 @@ export function render({ navigate }) {
const cancelEl = form.querySelector('#cancel-create-channel'); const cancelEl = form.querySelector('#cancel-create-channel');
let submitInFlight = false; 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) => { const setBusy = (busy) => {
submitInFlight = !!busy; submitInFlight = !!busy;
submitEl.disabled = submitInFlight; submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight; cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight; nameEl.disabled = submitInFlight;
titleEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight; descriptionEl.disabled = submitInFlight;
avatarBtn.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать'; submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
}; };
const updateValidation = () => { const updateValidation = () => {
const nameCheck = validateChannelDisplayName(nameEl.value); 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; descriptionErrorEl.textContent = descriptionCheck.error;
const descLength = Number(descriptionCheck.bytes || 0); titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
descriptionCounterEl.textContent = `${descLength} / 200 байт`; 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; submitEl.disabled = submitInFlight || !ok;
renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return { return {
ok, ok,
name: nameCheck.normalized, name: nameCheck.normalized,
title: titleCheck.normalized,
description: descriptionCheck.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); 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) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
@@ -121,6 +287,11 @@ export function render({ navigate }) {
} }
const check = updateValidation(); 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; if (!check.ok) return;
setBusy(true); setBusy(true);
@@ -131,10 +302,13 @@ export function render({ navigate }) {
login, login,
storagePwd, storagePwd,
channelName: normalizeChannelDisplayName(check.name), channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description), channelDescription: check.description,
channelProfileTitle: check.title,
channelAvatar: selectedAvatar,
channelType: CHANNEL_TYPE_PUBLIC, channelType: CHANNEL_TYPE_PUBLIC,
channelTypeVersion: 1, channelTypeVersion: 1,
}); });
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`); persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
navigate('channels-list'); navigate('channels-list');
@@ -150,5 +324,14 @@ export function render({ navigate }) {
screen.append(form); screen.append(form);
nameEl.focus(); nameEl.focus();
updateValidation(); 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; 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'; } from '../services/channels-ux.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.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 { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; 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 pendingReactionActions = new Set();
const pendingThreadScroll = new Map(); const pendingThreadScroll = new Map();
@@ -201,6 +215,46 @@ function resolveChannelDisplayName(channelSelector) {
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; 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) { function extractChannelContextFromThreadPayload(payload) {
const focusInfo = payload?.focus?.channelInfo; const focusInfo = payload?.focus?.channelInfo;
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { 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) { function resolveNodeText(node) {
return firstNonEmptyText( return firstNonEmptyText(
latestVersionText(node?.versions),
node?.text, node?.text,
node?.message, node?.message,
node?.body, 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'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="thread-reply-modal"> <div class="modal" id="thread-reply-modal">
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Ответ</h3> <h3 class="modal-title">${title}</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <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="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button> <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 textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error'); const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit'); const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false; let inFlight = false;
const setBusy = (busy) => { const setBusy = (busy) => {
inFlight = !!busy; inFlight = !!busy;
submitEl.disabled = inFlight; submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить'; submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
}; };
@@ -368,8 +540,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return; if (inFlight) return;
const text = String(textEl?.value || '').trim(); const text = String(textEl?.value || '').trim();
if (!text) { if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа.'; errorEl.textContent = emptyError;
return; return;
} }
@@ -377,11 +549,32 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = ''; errorEl.textContent = '';
try { try {
await onSubmit(text); await onSubmit(composeMessageWithAttachments(text, attachments));
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close(); close();
} catch (error) { } catch (error) {
setBusy(false); 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'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="thread-edit-modal"> <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-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => { root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim(); const value = String(textEl?.value || '').trim();
if (!value) { if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.'; errorEl.textContent = 'Введите текст сообщения.';
return; return;
} }
@@ -557,7 +750,6 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function renderNodeCard(node, heading, handlers, localNumber) { function renderNodeCard(node, heading, handlers, localNumber) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card stack thread-node-card channel-message-card'; card.className = 'card stack thread-node-card channel-message-card';
card.classList.add('is-counters-visible');
const author = node?.authorLogin || 'автор'; const author = node?.authorLogin || 'автор';
const versions = Array.isArray(node?.versions) ? node.versions : []; 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 text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
const likes = Number(node?.likesCount || 0); const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 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 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 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(); const headingText = String(heading || '').trim();
if (headingText) { if (headingText) {
@@ -581,6 +778,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button'); const authorTile = document.createElement('button');
authorTile.type = 'button'; authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile'; authorTile.className = 'channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const avatar = createThreadAvatar(author); const avatar = createThreadAvatar(author);
@@ -588,13 +787,16 @@ function renderNodeCard(node, heading, handlers, localNumber) {
authorBlock.className = 'channel-message-author'; authorBlock.className = 'channel-message-author';
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'channel-message-title author-line'; title.className = 'channel-message-title author-line';
const titleMain = document.createElement('div');
titleMain.className = 'author-line-main';
const loginEl = document.createElement('span'); const loginEl = document.createElement('span');
loginEl.className = 'author-line-login'; loginEl.className = 'author-line-login';
loginEl.textContent = author; loginEl.textContent = author;
const numberEl = document.createElement('span'); const numberEl = document.createElement('span');
numberEl.className = 'author-line-num'; numberEl.className = 'author-line-num';
numberEl.textContent = `· #${localNumber}`; numberEl.textContent = `· #${localNumber}`;
title.append(loginEl, numberEl); titleMain.append(loginEl, numberEl);
title.append(titleMain);
if (versionsTotal > 1) { if (versionsTotal > 1) {
const editedMarker = document.createElement('button'); const editedMarker = document.createElement('button');
editedMarker.type = 'button'; editedMarker.type = 'button';
@@ -616,13 +818,63 @@ function renderNodeCard(node, heading, handlers, localNumber) {
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); 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 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 target = buildTargetFromNode(node);
const refKey = messageRefKey(target); 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-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span> <span class="channel-action-counter">${likes}</span>
`; `;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -660,6 +913,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
} }
await longPressFeel(event.currentTarget, 130); await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true; likeButton.disabled = true;
setActionTitle(likeButton, 'Лайк...');
try { try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) { } catch (error) {
@@ -680,6 +934,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-label">Ответить</span> <span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span> <span class="channel-action-counter">${replies}</span>
`; `;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
@@ -688,6 +943,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
onSubmit: async (textValue) => handlers.onReply(target, textValue), 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'); const shareButton = document.createElement('button');
shareButton.type = '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-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span> <span class="channel-action-label">Отправить</span>
`; `;
setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => { shareButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
@@ -704,7 +978,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации. // Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton); actions.append(likeButton, replyButton, ratingButton, shareButton);
if (repostTarget) { if (repostTarget) {
const originalButton = document.createElement('button'); const originalButton = document.createElement('button');
originalButton.type = '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-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span> <span class="channel-action-label">Оригинал</span>
`; `;
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => { originalButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?'); const ok = window.confirm('Перейти к оригинальному сообщению?');
@@ -727,6 +1002,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}); });
actions.append(originalButton); 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) { if (isOwnMessage) {
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.type = 'button'; editButton.type = 'button';
@@ -740,8 +1036,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openEditMessageModal({ openEditMessageModal({
initialText: String(text || '').trim() === 'удалено' ? '' : text, initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }), allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }), onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
}); });
}); });
@@ -768,7 +1065,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
normalized.forEach((branch, index) => { normalized.forEach((branch, index) => {
try { try {
const nodeNumber = nextNumber(); 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.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4))); row.style.setProperty('--depth', String(Math.min(depth, 4)));
wrap.append(row); wrap.append(row);
@@ -817,7 +1114,7 @@ function renderSkeleton(screen) {
return wrap; return wrap;
} }
export function render({ navigate, route }) { export function render({ navigate, route, chrome }) {
const selector = parseThreadSelector(route); const selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel); const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`; 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.classList.add('channel-header-route-btn');
threadHeaderButton.disabled = true; threadHeaderButton.disabled = true;
} }
chrome?.setTopbar(header);
const statusBox = document.createElement('div'); const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status'; statusBox.className = 'card status-line is-unavailable channels-status';
@@ -913,6 +1211,15 @@ export function render({ navigate, route }) {
showStatus(''); showStatus('');
rerender(); 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) => { onRepost: async (target) => {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
const feed = await authService.listSubscriptionsFeed(login, 1000); 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) { if (!selector) {
const invalid = document.createElement('div'); const invalid = document.createElement('div');
@@ -1135,12 +1442,9 @@ export function render({ navigate, route }) {
if (ancestors.length) { if (ancestors.length) {
ancestorsWrap = document.createElement('div'); ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors'; 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) => { 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'; descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3'); const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title'; descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы'; descendantsTitle.textContent = 'Ответы и оценки';
descendantsWrap.append(descendantsTitle); descendantsWrap.append(descendantsTitle);
if (descendants.length) { if (descendants.length) {
@@ -1167,7 +1471,7 @@ export function render({ navigate, route }) {
} else { } else {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
empty.textContent = 'Ответов пока нет.'; empty.textContent = 'Ответов и оценок пока нет.';
descendantsWrap.append(empty); descendantsWrap.append(empty);
} }
File diff suppressed because it is too large Load Diff
+106 -27
View File
@@ -1,6 +1,7 @@
import { channels as mockChannels } from '../mock-data.js'; import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js'; import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js';
import { import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
@@ -12,6 +13,8 @@ import {
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.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: 'Каналы' }; 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 MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100; 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() { function isChannelsDemoMode() {
try { try {
@@ -610,7 +622,7 @@ function mapMockGroups() {
ownerBlockchainName: String(channel.ownerName || ''), ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id), channelName: String(channel.channelName || channel.title || channel.id),
}), }),
messagePreview: channel.lastMessage || 'Ждем ваших начинаний', messagePreview: cleanChannelMessagePreview(channel.lastMessage),
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal', isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal', isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
notificationsEnabled: false, notificationsEnabled: false,
@@ -646,11 +658,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно'; const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
const channelName = summary?.channel?.channelName || '(без названия)'; const channelName = summary?.channel?.channelName || '(без названия)';
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
const channelDescription = String(summary?.channel?.channelDescription || '').trim(); const channelDescription = String(summary?.channel?.channelDescription || '').trim();
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1); const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1); const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
const isOwn = bucketKey === 'own'; const isOwn = bucketKey === 'own';
const title = `${ownerLogin} / ${channelName}`; const title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`;
return { return {
id: rowId, id: rowId,
@@ -659,13 +673,16 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '', ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber), channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash), channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
avatar: avatarLetterFromName(channelName), avatar: avatarLetterFromName(displayTitle || channelName),
avaAr: String(summary?.channel?.avaAr || '').trim(),
title, title,
technicalLabel,
channelName, channelName,
displayTitle,
channelDescription, channelDescription,
channelTypeCode, channelTypeCode,
channelTypeVersion, channelTypeVersion,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний', messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagesCount: Number(summary?.messagesCount || 0), messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0), unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 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 index = {};
const ownChannels = (feed?.ownedChannels || []) const ownChannels = (feed?.ownedChannels || [])
.filter(isVisibleChannelSummary) .filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)); .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 || []) const followedUserChannels = (feed?.followedUsersChannels || [])
.filter(isVisibleChannelSummary) .filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState)); .map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
@@ -962,6 +1032,10 @@ function renderChannelMain(channel) {
title.className = 'channel-row-title'; title.className = 'channel-row-title';
title.textContent = channel.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) { if (channel.channelDescription) {
const desc = document.createElement('p'); const desc = document.createElement('p');
desc.className = 'channel-row-description'; desc.className = 'channel-row-description';
@@ -977,7 +1051,7 @@ function renderChannelMain(channel) {
meta.className = 'channel-row-owner channel-counter-meta'; meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`; meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(title); main.prepend(title, technical);
main.append(preview, meta); main.append(preview, meta);
return main; return main;
} }
@@ -1006,7 +1080,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const avatar = document.createElement('div'); const avatar = document.createElement('div');
avatar.className = 'avatar'; avatar.className = '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; avatar.textContent = channel.avatar;
}
const main = renderChannelMain(channel); const main = renderChannelMain(channel);
@@ -1016,7 +1097,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const time = document.createElement('span'); const time = document.createElement('span');
time.className = 'channel-row-time'; time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
const count = document.createElement('span'); const count = document.createElement('span');
count.className = 'unread channel-row-count'; count.className = 'unread channel-row-count';
@@ -1069,11 +1150,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list); container.append(list);
} }
function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) { function updateBottomCta({ button }) {
const baseClass = `primary-btn channels-bottom-action${isTabEmpty ? ' is-empty-lift' : ''}`; if (!button) return;
button.textContent = 'Поиск каналов'; button.hidden = true;
button.className = baseClass; button.textContent = '';
button.onclick = () => openChannelFinderModal({ navigate }); button.className = 'channels-bottom-action';
button.onclick = null;
} }
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
@@ -1103,7 +1185,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
try { try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200); 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); listState.channels = toListModel(groups);
setChannelsFeed(feed, groups.index); 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'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list'; screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
@@ -1222,26 +1310,17 @@ export function render({ navigate, route }) {
createInMyBtn.style.display = ''; createInMyBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle); if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
updateBottomCta({ updateBottomCta({ button: bottomCta });
button: bottomCta,
listState,
navigate,
isTabEmpty,
});
}; };
screen.append(topBarEl, contentEl, bottomCta); chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
if (createSuccessFlash) { if (createSuccessFlash) {
showToast(createSuccessFlash); showToast(createSuccessFlash);
} }
updateBottomCta({ updateBottomCta({ button: bottomCta });
button: bottomCta,
listState,
navigate,
isTabEmpty: true,
});
// Применяем корректное состояние хедера сразу на первом рендере, // Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки. // чтобы не показывать лишние кнопки до первой перерисовки.
+69 -42
View File
@@ -245,6 +245,8 @@ function openChatActionsMenu({
anchorX = 0, anchorX = 0,
anchorY = 0, anchorY = 0,
onCall, onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory, onClearHistory,
onDeleteChat, onDeleteChat,
}) { }) {
@@ -255,7 +257,9 @@ function openChatActionsMenu({
root.innerHTML = ` root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer"> <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}"> <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" 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> <button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div> </div>
@@ -303,6 +307,14 @@ function openChatActionsMenu({
close(); close();
if (typeof onCall === 'function') await onCall(); 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 () => { root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close(); close();
if (typeof onClearHistory === 'function') await onClearHistory(); if (typeof onClearHistory === 'function') await onClearHistory();
@@ -440,12 +452,12 @@ function buildCallStatusText(callSummary) {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`; return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
} }
const reason = String(callSummary.reason || '').trim().toLowerCase(); const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Не дозвонился: абонент не в сети'; if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Не дозвонился: нет ответа'; if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение'; if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Не дозвонился: абонент занят'; if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Не дозвонился: звонок отклонён'; if (reason === 'declined') return 'Звонок отклонён';
return 'Не дозвонился'; return 'Звонок не завершён';
} }
function resolveEffectiveReadState(messages, msg) { function resolveEffectiveReadState(messages, msg) {
@@ -594,20 +606,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
const callIcon = document.createElement('span'); const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon'; 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'); const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title'; callTitle.className = 'bubble-call-title';
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок'; callTitle.textContent = 'Звонок';
callHead.append(callIcon, callTitle); const callMetaLine = document.createElement('span');
callMetaLine.className = 'bubble-call-line';
const callMetaLine = document.createElement('div'); callMetaLine.classList.add(
callMetaLine.className = 'bubble-call-line bubble-call-line--muted'; parsedText.callSummary.status === 'completed'
? 'bubble-call-line--success'
: 'bubble-call-line--plain',
);
const callStatus = buildCallStatusText(parsedText.callSummary); const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus; callMetaLine.textContent = callStatus;
callCard.append(callHead, callMetaLine); callHead.append(callIcon, callTitle, callMetaLine);
callCard.append(callHead);
bubble.append(callCard); bubble.append(callCard);
} }
@@ -757,9 +783,10 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
function setChatKeyboardOpen(isOpen) { function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!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 routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || { 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); await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
}; };
const handleStartCall = async () => { const handleStartCall = async (mode = 'audio') => {
try { try {
await startOutgoingCall(chatId); await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) { } catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
@@ -845,7 +872,7 @@ export function render({ navigate, route }) {
const log = document.createElement('div'); const log = document.createElement('div');
log.className = 'messages-log dm-messages-log'; log.className = 'messages-log dm-messages-log';
screen.append( chrome?.setTopbar(
renderHeader({ renderHeader({
title: `Чат с ${contact.name}`, title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') }, leftAction: { label: '←', onClick: () => navigate('messages-list') },
@@ -855,10 +882,10 @@ export function render({ navigate, route }) {
ariaLabel: 'Позвонить', ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn', className: 'chat-header-icon-btn chat-header-call-btn',
iconNode: createHeaderPhoneHandsetIcon(), iconNode: createHeaderPhoneHandsetIcon(),
onClick: handleStartCall, onClick: () => handleStartCall('audio'),
}, },
{ {
label: '', label: '',
title: 'Действия чата', title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата', ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn', className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -866,7 +893,9 @@ export function render({ navigate, route }) {
openChatActionsMenu({ openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0), anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall, onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
onClearHistory: async () => { onClearHistory: async () => {
openChatConfirmModal({ openChatConfirmModal({
title: 'Очистить историю?', title: 'Очистить историю?',
@@ -913,7 +942,7 @@ export function render({ navigate, route }) {
}, },
}, },
], ],
}) }),
); );
if (!isKnownContact) { 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> <button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование"></button>
</div> </div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></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"> <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="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> <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 emojiPickerOpen = false;
let emojiSelection = null; let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); 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 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 viewportShrunk = baseViewportHeight - viewportHeight > 120;
const keyboardInset = viewportShrunk
? Math.max(0, baseViewportHeight - viewportHeight)
: 0;
setKeyboardInset(keyboardInset);
setChatKeyboardOpen(inputFocused && viewportShrunk); setChatKeyboardOpen(inputFocused && viewportShrunk);
if (viewportShrunk && window.scrollY !== 0) {
window.scrollTo(0, 0);
}
if (inputFocused) { if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
} }
@@ -1427,23 +1469,6 @@ export function render({ navigate, route }) {
if (!emojiPickerOpen || form.contains(event.target)) return; if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker(); 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) => { form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault(); event.preventDefault();
try { try {
@@ -1479,7 +1504,8 @@ export function render({ navigate, route }) {
window.visualViewport?.addEventListener('resize', syncKeyboardUi); window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi); window.addEventListener('resize', syncKeyboardUi);
wrap.append(historyLoader, log, form); chrome?.setComposer(form);
wrap.append(historyLoader, log);
screen.append(wrap); screen.append(wrap);
renderLog(log, chatId, { renderLog(log, chatId, {
onOpenActions: handleOpenActions, onOpenActions: handleOpenActions,
@@ -1499,6 +1525,7 @@ export function render({ navigate, route }) {
}); });
screen.cleanup = () => { screen.cleanup = () => {
setChatKeyboardOpen(false); setChatKeyboardOpen(false);
setKeyboardInset(0);
stopAllTwemojiAnimations(); stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi); 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>'; 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'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen'; screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim(); 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(); loadList();
return screen; return screen;
} }
+2 -2
View File
@@ -87,13 +87,13 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
export function render({ navigate }) { export function render({ navigate, chrome }) {
const login = state.session.login || profile.login; const login = state.session.login || profile.login;
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack profile-screen'; screen.className = 'stack profile-screen';
screen.append( chrome?.setTopbar(
renderHeader({ renderHeader({
title: 'Редактирование профиля', title: 'Редактирование профиля',
leftAction: { label: '←', onClick: () => navigate('profile-view') }, leftAction: { label: '←', onClick: () => navigate('profile-view') },
+10 -6
View File
@@ -92,15 +92,16 @@ function shineInfoText() {
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.'; + '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
} }
export function render({ navigate }) { export function render({ navigate, chrome }) {
const login = state.session.login || profile.login; const login = state.session.login || profile.login;
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack profile-screen'; screen.className = 'stack profile-screen';
const topActions = document.createElement('div'); const topbar = document.createElement('header');
topActions.className = 'profile-top-actions'; topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topActions.innerHTML = ` 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="Редактировать профиль"> <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" /> <img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
</button> </button>
@@ -110,10 +111,13 @@ export function render({ navigate }) {
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки"> <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" /> <img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</button> </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="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')); topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
chrome?.setTopbar(topbar);
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack profile-main-card'; 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); screen.append(card);
updateAvatarUi(); updateAvatarUi();
+21 -248
View File
@@ -4,34 +4,17 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
checkLoginExistsOnSolana, checkLoginExistsOnSolana,
formatSolanaErrorDetails, formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
precheckLoginClassOnSolana, precheckLoginClassOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
import { import {
composePasswordFromWords,
emptyPasswordWords, emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH, PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js'; } from '../services/password-words.js';
import { sha256Text } from '../services/crypto-utils.js';
import { openRegistrationFaq } from './registration-faq-view.js'; import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
// ВРЕМЕННАЯ UI-ЗАГЛУШКА: const MIN_REGISTRATION_LOGIN_LENGTH = 8;
// пока полноценная логика продажи/выдачи коротких имён не внедрена 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;
}
function normalizeLoginForTemporaryUiGuard(login) { function normalizeLoginForTemporaryUiGuard(login) {
const source = String(login || '').trim(); const source = String(login || '').trim();
@@ -46,48 +29,6 @@ function normalizeLoginForTemporaryUiGuard(login) {
return normalized; 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() { function makePasswordToggleIcons() {
return { return {
eye: ` eye: `
@@ -115,10 +56,11 @@ export function render({ navigate }) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'card stack registration-form'; form.className = 'card stack registration-form';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; state.registrationDraft.passwordMode = 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); state.registrationDraft.passwordWords = emptyPasswordWords();
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked); state.registrationDraft.passwordWordsLinked = false;
let usePromoCode = Boolean(state.registrationDraft.usePromoCode); state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
let loginCheckTimer = 0; let loginCheckTimer = 0;
let loginCheckRunId = 0; let loginCheckRunId = 0;
@@ -155,67 +97,6 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row'; passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton); 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'); const statusText = document.createElement('p');
statusText.className = 'status-line registration-login-status'; statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none'; statusText.style.display = 'none';
@@ -239,7 +120,6 @@ export function render({ navigate }) {
nextButton.type = 'button'; nextButton.type = 'button';
nextButton.textContent = 'Далее'; nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p'); const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'password-length-hint'; passwordLengthText.className = 'password-length-hint';
let lastCheckedLogin = ''; let lastCheckedLogin = '';
@@ -251,13 +131,8 @@ export function render({ navigate }) {
return String(passwordInput.value || ''); return String(passwordInput.value || '');
} }
function updateWordsPreview() { function updatePasswordLength() {
const password = passwordMode === 'words' passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
? composePasswordFromWords(passwordWords)
: String(passwordInput.value || '');
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
} }
function setStatusMessage(message, kind = '') { function setStatusMessage(message, kind = '') {
@@ -295,32 +170,14 @@ export function render({ navigate }) {
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль'); 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() { function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim()); 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.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 } = {}) { async function runAvailabilityCheck({ automatic = false } = {}) {
const runId = ++loginCheckRunId; const runId = ++loginCheckRunId;
const login = loginInput.value.trim(); const login = loginInput.value.trim();
const promoCode = String(promoInput.value || '').trim();
if (!login) { if (!login) {
setStatusMessage(automatic ? '' : 'Введите логин'); setStatusMessage(automatic ? '' : 'Введите логин');
formError.style.display = 'none'; formError.style.display = 'none';
@@ -334,28 +191,8 @@ export function render({ navigate }) {
return false; return false;
} }
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) { if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable'); setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, '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');
formError.style.display = 'none'; formError.style.display = 'none';
return false; return false;
} }
@@ -363,8 +200,6 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) { if (login === lastCheckedLogin) {
if (!lastCheckedFree) { if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable'); setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (lastCheckedClassName === 'free') { } else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available'); setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') { } else if (lastCheckedClassName === 'premium') {
@@ -375,7 +210,7 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
} }
formError.style.display = 'none'; formError.style.display = 'none';
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo'); return lastCheckedFree && lastCheckedClassName === 'free';
} }
setStatusMessage('Проверяем логин...'); setStatusMessage('Проверяем логин...');
@@ -388,9 +223,6 @@ export function render({ navigate }) {
const isFree = !check.exists; const isFree = !check.exists;
let className = ''; let className = '';
if (isFree) { if (isFree) {
if (promoAccepted) {
className = 'promo';
} else {
try { try {
const precheck = await precheckLoginClassOnSolana({ const precheck = await precheckLoginClassOnSolana({
login, login,
@@ -402,14 +234,11 @@ export function render({ navigate }) {
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError)); console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
} }
} }
}
lastCheckedLogin = login; lastCheckedLogin = login;
lastCheckedFree = isFree; lastCheckedFree = isFree;
lastCheckedClassName = className; lastCheckedClassName = className;
if (!isFree) { if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable'); setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (className === 'free') { } else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available'); setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') { } else if (className === 'premium') {
@@ -420,9 +249,13 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
} }
formError.style.display = 'none'; formError.style.display = 'none';
return isFree && (className === 'free' || className === 'promo'); return isFree && className === 'free';
} catch (error) { } catch (error) {
if (runId !== loginCheckRunId) return false; if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин'); const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error); const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable'); setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
@@ -438,65 +271,12 @@ export function render({ navigate }) {
}); });
passwordInput.addEventListener('input', () => { passwordInput.addEventListener('input', () => {
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
passwordWords = emptyPasswordWords();
passwordWordsLinked = false;
wordInputs.forEach((input) => {
input.value = '';
});
}
syncDraftState(); syncDraftState();
updateWordsPreview(); updatePasswordLength();
});
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();
}); });
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
formError.style.display = 'none'; formError.style.display = 'none';
const promoCode = String(promoInput.value || '').trim();
if (usePromoCode && !promoCode) {
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
formError.style.display = '';
return;
}
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
if (!isFree) return; if (!isFree) return;
@@ -518,11 +298,6 @@ export function render({ navigate }) {
state.registrationDraft.login = nextLogin; state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword; 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) { if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null; state.registrationDraft.preGeneratedKeyBundle = null;
} }
@@ -539,14 +314,12 @@ export function render({ navigate }) {
passwordLabel.className = 'stack registration-password-single'; passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>'; passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel); form.append(loginField, statusText, passwordLabel);
passwordField = passwordLabel;
loginField.append(loginInput); loginField.append(loginInput);
passwordField.append(passwordInputRow); passwordLabel.append(passwordInputRow);
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton); form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = ''; actions.innerHTML = '';
actions.append(nextButton); actions.append(nextButton);
updatePasswordModeVisibility(); updatePasswordLength();
updatePromoVisibility();
syncDraftState(); syncDraftState();
} }
+1 -11
View File
@@ -62,17 +62,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [ paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.', 'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.', 'Хороший практический ориентир для большинства людей: 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 { import {
checkLoginExistsOnSolana, checkLoginExistsOnSolana,
formatSolanaErrorDetails, formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
isUserAlreadyExistsSolanaError, isUserAlreadyExistsSolanaError,
registerUserOnSolana, registerUserOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
@@ -311,6 +312,16 @@ export function render({ navigate }) {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle; const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.'); 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 (смарт контракт) // Регистрация на Solana (смарт контракт)
submitButton.textContent = 'Регистрация в Solana...'; submitButton.textContent = 'Регистрация в Solana...';
let registrationTxId = ''; let registrationTxId = '';
@@ -325,15 +336,20 @@ export function render({ navigate }) {
registrationTxId = String(registrationResult?.signature || '').trim(); registrationTxId = String(registrationResult?.signature || '').trim();
} catch (solanaError) { } catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError); const solanaMsg = formatSolanaErrorDetails(solanaError);
// Пользователь уже зарегистрирован в Solana — продолжаем if (solanaMsg.includes('already') || isUserAlreadyExistsSolanaError(solanaError)) {
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) { throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
} }
if (isSolanaRpcUnavailableError(solanaError)) {
throw new Error('Нет связи с сервером Solana. Попробуйте ещё раз позже.');
}
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
} }
renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId }); renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); const message = isSolanaRpcUnavailableError(error)
? 'Нет связи с сервером Solana. Попробуйте ещё раз позже.'
: toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message); setAuthError(message);
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.textContent = message; 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 class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span> </span>
</button> </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-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</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-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-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-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')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout'); 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 ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
const PRETTY_PATHS = new Map([ const PRETTY_PATHS = new Map([
@@ -31,6 +31,7 @@ const PRETTY_PATHS = new Map([
['settings-view', 'settings'], ['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'], ['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'], ['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'], ['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'], ['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'], ['language-view', 'settings/language'],
@@ -89,9 +90,11 @@ export function getRoute() {
}; };
const isLikelyBlockNumber = (value) => /^[0-9]+$/.test(String(value || '').trim()); const isLikelyBlockNumber = (value) => /^[0-9]+$/.test(String(value || '').trim());
const shineLogin = parseShineRootSegment(pageId); const shineRoute = parseShineRouteParts(segments);
if (shineLogin) { if (shineRoute?.entity) {
const section = decodePart(segments[1] || '').toLowerCase(); const shineLogin = shineRoute.entity;
const baseOffset = Number(shineRoute.offset || 0);
const section = decodePart(segments[baseOffset] || '').toLowerCase();
if (!section) { if (!section) {
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } }; 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 } }; return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
} }
if (section === 'channels') { if (section === 'channels') {
const sub = decodePart(segments[2] || '').toLowerCase(); const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
if (sub === 'owned') { if (sub === 'owned') {
return { return {
pageId: 'channels-list', pageId: 'channels-list',
@@ -117,12 +120,12 @@ export function getRoute() {
params: { mode: 'feed', login: shineLogin, scope: 'all' }, params: { mode: 'feed', login: shineLogin, scope: 'all' },
}; };
} }
if (segments.length === 2 && isLikelyBlockNumber(segments[1])) { if (segments.length === baseOffset + 1 && isLikelyBlockNumber(segments[baseOffset])) {
return { return {
pageId: 'channel-thread-view', pageId: 'channel-thread-view',
params: { params: {
messageBlockchainName: shineLogin, messageBlockchainName: shineLogin,
messageBlockNumber: segments[1] || '', messageBlockNumber: segments[baseOffset] || '',
messageBlockHash: '', messageBlockHash: '',
channelOwnerBlockchainName: '', channelOwnerBlockchainName: '',
channelRootBlockNumber: '', channelRootBlockNumber: '',
@@ -130,10 +133,10 @@ export function getRoute() {
}, },
}; };
} }
if (segments.length >= 2) { if (segments.length >= baseOffset + 1) {
const ownerBlockchainName = shineLogin; const ownerBlockchainName = shineLogin;
const channelName = decodePart(segments[1] || ''); const channelName = decodePart(segments[baseOffset] || '');
const messageBlockNumber = segments[2] || ''; const messageBlockNumber = segments[baseOffset + 1] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) { if (ownerBlockchainName && channelName && messageBlockNumber) {
return { return {
pageId: 'channel-thread-view', pageId: 'channel-thread-view',
@@ -261,6 +264,7 @@ export function getRoute() {
const sub = decodePart(segments[1] || '').toLowerCase(); const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} }; if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-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 === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} }; if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} }; if (sub === 'language') return { pageId: 'language-view', params: {} };
@@ -373,6 +377,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'access-servers-view' || pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' || pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' || pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' || pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' || pageId === 'device-view' ||
pageId === 'connect-device-view' || pageId === 'connect-device-view' ||
+39 -9
View File
@@ -1,6 +1,7 @@
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net'; const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const WINSTON_PER_AR = 1_000_000_000_000n; const WINSTON_PER_AR = 1_000_000_000_000n;
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024; const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768; const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86; const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/; const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -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') { if (!jwk || typeof jwk !== 'object') {
throw new Error('Arweave-кошелёк не инициализирован.'); throw new Error('Arweave-кошелёк не инициализирован.');
} }
if (!(file instanceof File)) { if (!(file instanceof File)) {
throw new Error('Выберите файл изображения.'); throw new Error('Выберите файл.');
} }
const normalizedGateway = normalizeGateway(gateway); const normalizedGateway = normalizeGateway(gateway);
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
const tx = await arweave.createTransaction({ data }, jwk); const tx = await arweave.createTransaction({ data }, jwk);
tx.addTag('Content-Type', String(file.type || 'application/octet-stream')); tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE'); tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar'); tx.addTag('SHiNE-Type', String(shineType || 'file'));
const extraTags = Array.isArray(tags) ? tags : []; const extraTags = Array.isArray(tags) ? tags : [];
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login'); extraTags.forEach((item) => {
const profileLogin = String(profileLoginTag?.value || '').trim(); const name = String(item?.name || '').trim();
if (!profileLogin) { const value = String(item?.value || '').trim();
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login'); if (name && value) tx.addTag(name, value);
} });
tx.addTag('SHiNE-Profile-Login', profileLogin);
await arweave.transactions.sign(tx, jwk); await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx); 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) { function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return; if (typeof onStatus !== 'function') return;
try { 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 } = {}) { export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').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);
}
+320 -90
View File
@@ -47,13 +47,29 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1; const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2; const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3; const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11; const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20; const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21; 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_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@@ -64,6 +80,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200; const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1; const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
const SESSION_TYPE_CLIENT = 1; const SESSION_TYPE_CLIENT = 1;
const SESSION_TYPE_WALLET = 50; const SESSION_TYPE_WALLET = 50;
const SESSION_TYPE_HOMESERVER = 100; 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({ function makeTextRepostBodyBytes({
lineCode, lineCode,
prevLineNumber, prevLineNumber,
@@ -690,14 +768,47 @@ function makeConnectionBodyBytes({
} }
function normalizeChannelDescription(value) { 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); const bytes = utf8Bytes(text);
if (bytes.length > 200) { if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.'); throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
} }
return text; 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) { function validatePersonalChannelName(value) {
const normalized = normalizeChannelDisplayName(value); const normalized = normalizeChannelDisplayName(value);
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' }; if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
@@ -737,8 +848,8 @@ function makeCreateChannelBodyBytes({
} }
const descriptionBytes = utf8Bytes(cleanDescription); const descriptionBytes = utf8Bytes(cleanDescription);
if (descriptionBytes.length > 200) { if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.'); throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
} }
const typeVer = Number(channelTypeVersion); const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) { 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 }) { function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim(); const message = String(text || '').trim();
if (!message) throw new Error('Message text is required'); 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') { function normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || '').trim(); const cleanBch = String(target?.blockchainName || '').trim();
const cleanBlockNumber = Number(target?.blockNumber); const cleanBlockNumber = Number(target?.blockNumber);
@@ -1400,6 +1503,13 @@ export class AuthService {
return response.payload || {}; 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 = '') { async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = { const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(), 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 }) { async addBlockRepost({ login, channel, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login'); if (!cleanLogin) throw new Error('Missing login');
@@ -2033,6 +2208,8 @@ export class AuthService {
login, login,
channelName, channelName,
channelDescription = '', channelDescription = '',
channelProfileTitle = '',
channelAvatar = null,
channelType = CHANNEL_TYPE_PUBLIC, channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT, channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd, storagePwd,
@@ -2048,7 +2225,13 @@ export class AuthService {
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code)); throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
} }
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized); 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 channelSlug = toCanonicalChannelSlug(cleanChannelName);
const typeVersion = Number(channelTypeVersion); const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`; const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
@@ -2080,9 +2263,7 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1; thisLineNumber = createdChannels.length + 1;
} }
let payload; const payload = await this.addBlockSigned({
try {
payload = await this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
msgType: MSG_TYPE_TECH, msgType: MSG_TYPE_TECH,
@@ -2099,32 +2280,6 @@ export class AuthService {
channelTypeVersion: typeVersion, 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 selector = { const selector = {
ownerBlockchainName: blockchainName, ownerBlockchainName: blockchainName,
@@ -2141,21 +2296,10 @@ export class AuthService {
}); });
} }
async addBlockTextPost({ login, channel, text, storagePwd }) { async resolveChannelLineTail({ login, blockchainName, channel }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
const selector = channel || {}; const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim(); const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber); const lineCode = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${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) { if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
throw new Error('Invalid channel selector'); throw new Error('Invalid channel selector');
} }
@@ -2165,7 +2309,7 @@ export class AuthService {
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) { if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName); const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode); const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found'); if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
@@ -2179,28 +2323,73 @@ export class AuthService {
ownerBlockchainName, ownerBlockchainName,
channelRootBlockNumber: lineCode, channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex, channelRootBlockHash: rootHashHex,
}, 1, 'desc', cleanLogin); }, 1, 'desc', login);
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber); const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, ''); .slice()
const latestLineStep = resolveLatestLineStep(latestMessage); .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) { if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber; prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash; prevLineHashHex = latestBlockHash;
// Для нового POST берём следующий шаг после последнего сообщения линии. thisLineNumber = latest?.kind === 'created'
thisLineNumber = Number.isFinite(latestLineStep) ? 0
: Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1) ? Math.max(0, latestLineStep + 1)
: 1; : (latestBlockNumber === lineCode ? 0 : 1);
} }
} catch { } catch {
// fallback to root anchor // fallback to root anchor
} }
const bodyBytes = makeTextPostBodyBytes({ return {
ownerBlockchainName,
lineCode, lineCode,
rootHashHex,
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, 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}:${cleanSubType}:${cleanText}`;
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 = makeTextPostBodyBytes({
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: cleanText, text: cleanText,
}); });
@@ -2208,7 +2397,7 @@ export class AuthService {
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
msgType: MSG_TYPE_TEXT, msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_POST, msgSubType: cleanSubType,
msgVersion: 1, msgVersion: 1,
bodyBytes, bodyBytes,
}); });
@@ -2216,9 +2405,50 @@ export class AuthService {
return { return {
...payload, ...payload,
channel: { channel: {
ownerBlockchainName, ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: lineCode, channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: rootHashHex, 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 }) { async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
if (response.status !== 200) throw opError('CallInviteBroadcast', response); if (response.status !== 200) throw opError('CallInviteBroadcast', response);
return response.payload || {}; return response.payload || {};
} }
File diff suppressed because it is too large Load Diff
+252 -8
View File
@@ -2,10 +2,13 @@ import {
acceptIncomingCall, acceptIncomingCall,
declineIncomingCall, declineIncomingCall,
hangupActiveCall, hangupActiveCall,
setCameraEnabled,
setMicMuted, setMicMuted,
subscribeCallState, subscribeCallState,
toggleSpeakerMode, toggleSpeakerMode,
} from './call-service.js'; } from './call-service.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from './user-profile-params.js';
const MINIMIZED_BAR_HEIGHT_PX = 44; const MINIMIZED_BAR_HEIGHT_PX = 44;
@@ -15,10 +18,20 @@ let panelEl = null;
let headerEl = null; let headerEl = null;
let titleEl = null; let titleEl = null;
let statusEl = 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 incomingActionsEl = null;
let activeActionsEl = null; let activeActionsEl = null;
let speakerBtn = null; let speakerBtn = null;
let muteBtn = null; let muteBtn = null;
let cameraBtn = null;
let acceptBtn = null; let acceptBtn = null;
let declineBtn = null; let declineBtn = null;
let hangupBtn = null; let hangupBtn = null;
@@ -28,11 +41,22 @@ let barTextEl = null;
let barActionsEl = null; let barActionsEl = null;
let barSpeakerBtn = null; let barSpeakerBtn = null;
let barMuteBtn = null; let barMuteBtn = null;
let barCameraBtn = null;
let barHangupBtn = null; let barHangupBtn = null;
let unbind = null; let unbind = null;
let tickerId = 0; let tickerId = 0;
let currentSnapshot = null; let currentSnapshot = null;
let minimized = false; 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() { function resolveAppShell() {
return document.querySelector('.app-shell') || document.body; return document.querySelector('.app-shell') || document.body;
@@ -60,7 +84,68 @@ function getSnapshotElapsedMs(snapshot) {
function buildBarText(snapshot) { function buildBarText(snapshot) {
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь'; const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot)); 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) { function stopEvent(event) {
@@ -68,6 +153,10 @@ function stopEvent(event) {
event.stopPropagation(); event.stopPropagation();
} }
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getIconSvg(name) { function getIconSvg(name) {
if (name === 'mic-on') { 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>'; 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') { 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>'; 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 ''; return '';
} }
@@ -113,6 +208,15 @@ function updateSpeakerButtons(enabled) {
setButtonIcon(barSpeakerBtn, iconName, label); 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) { async function handleMuteClick(event) {
stopEvent(event); stopEvent(event);
const nowMuted = event.currentTarget?.dataset?.muted === '1'; const nowMuted = event.currentTarget?.dataset?.muted === '1';
@@ -134,6 +238,14 @@ async function handleSpeakerClick(event) {
await toggleSpeakerMode(); await toggleSpeakerMode();
} }
async function handleCameraClick(event) {
stopEvent(event);
const enabled = event.currentTarget?.dataset?.enabled === '1';
try {
await setCameraEnabled(!enabled);
} catch {}
}
function setMinimized(nextValue) { function setMinimized(nextValue) {
minimized = Boolean(nextValue); minimized = Boolean(nextValue);
render(); 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() { function ensureUi() {
if (rootEl) return; if (rootEl) return;
@@ -185,12 +351,17 @@ function ensureUi() {
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barMuteBtn.addEventListener('click', handleMuteClick); 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 = document.createElement('button');
barHangupBtn.type = 'button'; barHangupBtn.type = 'button';
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger'; barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
barHangupBtn.addEventListener('click', handleEndCallClick); barHangupBtn.addEventListener('click', handleEndCallClick);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn); barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn);
barEl.append(barTextEl, barActionsEl); barEl.append(barTextEl, barActionsEl);
overlayEl = document.createElement('div'); overlayEl = document.createElement('div');
@@ -203,7 +374,7 @@ function ensureUi() {
headerEl = document.createElement('div'); headerEl = document.createElement('div');
headerEl.className = 'call-overlay-head'; headerEl.className = 'call-overlay-head';
titleEl = document.createElement('h2'); titleEl = document.createElement('div');
titleEl.className = 'call-overlay-title'; titleEl.className = 'call-overlay-title';
minimizeBtn = document.createElement('button'); minimizeBtn = document.createElement('button');
@@ -219,6 +390,47 @@ function ensureUi() {
statusEl = document.createElement('div'); statusEl = document.createElement('div');
statusEl.className = 'call-overlay-status'; 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 = document.createElement('div');
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming'; 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.className = 'call-icon-btn call-icon-btn--secondary';
muteBtn.addEventListener('click', handleMuteClick); 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 = document.createElement('button');
acceptBtn.type = 'button'; acceptBtn.type = 'button';
acceptBtn.className = 'call-accept-btn'; acceptBtn.className = 'call-accept-btn';
@@ -258,9 +475,9 @@ function ensureUi() {
hangupBtn.addEventListener('click', handleEndCallClick); hangupBtn.addEventListener('click', handleEndCallClick);
incomingActionsEl.append(acceptBtn, declineBtn); incomingActionsEl.append(acceptBtn, declineBtn);
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn); activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn);
headerEl.append(titleEl, minimizeBtn); headerEl.append(titleEl, minimizeBtn);
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl); panelEl.append(mediaStageEl, headerEl, statusEl, incomingActionsEl, activeActionsEl);
overlayEl.append(panelEl); overlayEl.append(panelEl);
rootEl.append(barEl, overlayEl); rootEl.append(barEl, overlayEl);
resolveAppShell().append(rootEl); resolveAppShell().append(rootEl);
@@ -270,13 +487,21 @@ function applyExpandedState(snapshot) {
overlayEl.hidden = false; overlayEl.hidden = false;
barEl.hidden = true; barEl.hidden = true;
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`; titleEl.textContent = '';
statusEl.textContent = snapshot.statusText || ''; 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 incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
const muted = Boolean(snapshot.muted); const muted = Boolean(snapshot.muted);
updateMuteButtons(muted); updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); 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.hidden = incomingStage;
minimizeBtn.disabled = incomingStage; minimizeBtn.disabled = incomingStage;
@@ -290,9 +515,25 @@ function applyExpandedState(snapshot) {
muteBtn.hidden = !snapshot.canMute; muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute;
cameraBtn.hidden = !snapshot.canToggleCamera;
cameraBtn.disabled = !snapshot.canToggleCamera;
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer; acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
declineBtn.hidden = !incomingStage || !snapshot.canDecline; declineBtn.hidden = !incomingStage || !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup; 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) { function applyMinimizedState(snapshot) {
@@ -303,15 +544,18 @@ function applyMinimizedState(snapshot) {
const muted = Boolean(snapshot.muted); const muted = Boolean(snapshot.muted);
updateMuteButtons(muted); updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
updateCameraButtons(Boolean(snapshot.cameraEnabled));
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker; barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker; barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
barMuteBtn.hidden = incomingStage || !snapshot.canMute; barMuteBtn.hidden = incomingStage || !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute; barMuteBtn.disabled = !snapshot.canMute;
barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera;
barCameraBtn.disabled = !snapshot.canToggleCamera;
const canEnd = !incomingStage && snapshot.canHangup; const canEnd = !incomingStage && snapshot.canHangup;
barHangupBtn.hidden = !canEnd; barHangupBtn.hidden = !canEnd;
barHangupBtn.disabled = !canEnd; barHangupBtn.disabled = !canEnd;
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку'); setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd); barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd);
} }
function syncShellState() { function syncShellState() {
+10 -7
View File
@@ -47,12 +47,12 @@ function buildCallDisplayText(callSummary) {
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`; return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
} }
const reason = String(callSummary.reason || '').trim().toLowerCase(); const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети'; if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа'; if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение'; if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят'; if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён'; if (reason === 'declined') return 'Звонок отклонён';
return 'Звонил, но недозвонился'; return 'Звонок не завершён';
} }
export function sanitizeUserDmTextForSend(rawText = '') { export function sanitizeUserDmTextForSend(rawText = '') {
@@ -118,7 +118,10 @@ export function parseDmTechBlocks(rawText = '') {
const status = String(fields.status || '').trim().toLowerCase(); const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') { if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0))); const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec }; callSummary = {
status: 'completed',
durationSec,
};
} else { } else {
callSummary = { callSummary = {
status: 'failed', status: 'failed',
+28 -12
View File
@@ -10,6 +10,8 @@ function decodeRoutePart(value = '') {
} }
} }
const SHINE_ROUTE_SEGMENT = 'SHiNE';
export function normalizeLogin(value = '') { export function normalizeLogin(value = '') {
return String(value || '').trim().replace(/^@+/, ''); return String(value || '').trim().replace(/^@+/, '');
} }
@@ -23,21 +25,21 @@ export function extractLoginFromBlockchainName(value = '') {
export function makeProfileRoute(login = '') { export function makeProfileRoute(login = '') {
const clean = normalizeLogin(login); const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile'; return clean ? `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}` : 'profile';
} }
export function makeProfileLinksRoute(login = '') { export function makeProfileLinksRoute(login = '') {
const clean = normalizeLogin(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') { export function makeProfileChannelsRoute(login = '', scope = 'all') {
const clean = normalizeLogin(login); const clean = normalizeLogin(login);
if (!clean) return 'channels/feed'; if (!clean) return 'channels/feed';
const normalizedScope = String(scope || '').trim().toLowerCase(); const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`; if (normalizedScope === 'owned') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`; if (normalizedScope === 'following') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/following`;
return `shine.${encodeRoutePart(clean)}/channels`; return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels`;
} }
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) { export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
@@ -45,19 +47,33 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
const chName = String(channelName || '').trim(); const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim(); const msgNo = String(messageBlockNumber || '').trim();
if (!ownerBch || !chName) return ''; if (!ownerBch || !chName) return '';
if (msgNo) return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`; if (msgNo) return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
} }
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) { export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const msgBch = String(messageBlockchainName || '').trim(); const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim(); const msgNo = String(messageBlockNumber || '').trim();
if (!msgBch || !msgNo) return ''; if (!msgBch || !msgNo) return '';
return `shine.${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`; return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
} }
export function parseShineRootSegment(segment = '') { export function parseShineRouteParts(segments = []) {
const raw = String(segment || '').trim(); const parts = Array.isArray(segments) ? segments.map((value) => String(value || '').trim()) : [];
if (!raw.toLowerCase().startsWith('shine.')) return ''; if (!parts.length) return null;
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
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, solanaEndpoint,
}); });
const accessServers = Array.isArray(parsedUser?.accessServers) 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); const pickedServerLogin = accessServers[0] || normalizeShineServerLogin(fallbackServerLogin);
if (!pickedServerLogin) { if (!pickedServerLogin) {
+18 -3
View File
@@ -30,6 +30,7 @@ const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1; const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1; const SESSION_TYPE_USER = 1;
const SESSION_TYPE_HOMESERVER = 100; const SESSION_TYPE_HOMESERVER = 100;
const MAX_EFFECTIVE_ACCESS_SERVERS = 2;
let solanaLibPromise = null; let solanaLibPromise = null;
function loadSolanaLib() { function loadSolanaLib() {
@@ -41,6 +42,19 @@ function normalizeLogin(login) {
return String(login || '').trim().toLowerCase(); 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) { function pushU32LE(buf, value) {
const n = Number(value) >>> 0; const n = Number(value) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff); 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); buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value); 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); buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value); for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff); buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
@@ -252,7 +266,7 @@ function serializeUpdateUserPdaArgs(args) {
buf.push(syncServers.length & 0xff); buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value); 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); buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value); for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff); buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
@@ -337,7 +351,7 @@ function createPdaState({
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0, addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
serverAddress: serverProfile?.serverAddress ?? '', serverAddress: serverProfile?.serverAddress ?? '',
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [], syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
accessServers: Array.isArray(accessServers) ? [...accessServers] : [], accessServers: normalizeAccessServers(accessServers),
sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff, sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff,
sessions: Array.isArray(sessions) ? sessions.map((x) => ({ sessions: Array.isArray(sessions) ? sessions.map((x) => ({
sessionType: Number(x?.sessionType || 0) & 0xff, sessionType: Number(x?.sessionType || 0) & 0xff,
@@ -493,6 +507,7 @@ export function parseShineUserPda(dataBytes) {
const accessCount = reader.readU8(); const accessCount = reader.readU8();
accessServers = []; accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8()); for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
accessServers = normalizeAccessServers(accessServers);
continue; continue;
} }
if (blockType === BLOCK_TYPE_SESSIONS) { if (blockType === BLOCK_TYPE_SESSIONS) {
@@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) {
return parts.join(' :: '); 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) { export function isUserAlreadyExistsSolanaError(error) {
const details = formatSolanaErrorDetails(error); const details = formatSolanaErrorDetails(error);
return details.includes('UserAlreadyExists') return details.includes('UserAlreadyExists')

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