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
87 changed files with 5907 additions and 1301 deletions
@@ -36,6 +36,10 @@ 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_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_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes); yield new TextLineBody(subType, version, bodyBytes);
@@ -46,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,17 +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 — скрытый технический снимок профиля канала. * CHANNEL_META — скрытый технический снимок профиля канала.
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст. * Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
*/ */
public static final short TEXT_CHANNEL_META = 70; 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) ===================== */
@@ -144,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;
} }
@@ -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,12 +16,16 @@ import java.util.Objects;
* subType: * subType:
* - POST (10) * - POST (10)
* - EDIT_POST (11) * - EDIT_POST (11)
* - REPOST (30) * - REPOST (50)
* - CHANNEL_META (70) * - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* *
* POST / CHANNEL_META: * POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
@@ -91,7 +95,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
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_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); throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
} }
@@ -159,12 +167,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
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_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"); 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");
} }
@@ -211,7 +223,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
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_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");
@@ -238,8 +254,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else { } else {
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null"); if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
} else if (message == null || message.isBlank()) { } 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/CHANNEL_META must not contain target fields"); throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
@@ -254,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) || st == (MsgSubType.TEXT_CHANNEL_META & 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
@@ -318,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());
}
}
@@ -32,8 +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_CHANNEL_META = 70; 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;
@@ -30,11 +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 — скрытый технический снимок профиля канала. */ /** CHANNEL_META — скрытый технический снимок профиля канала. */
public static final short TEXT_CHANNEL_META = 70; 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) ===================== */
@@ -123,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 — лучше добавить новые значения,
// не трогая уже занятые коды. // не трогая уже занятые коды.
@@ -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()) {
@@ -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);
@@ -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);
@@ -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;
@@ -167,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;
@@ -388,6 +392,33 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); 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
int expectedBlockNumber = serverLastNum + 1; int expectedBlockNumber = serverLastNum + 1;
if (block.blockNumber != expectedBlockNumber) { if (block.blockNumber != expectedBlockNumber) {
@@ -605,6 +636,63 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
String slug; 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 { private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) 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("""
@@ -22,6 +22,7 @@ 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_ADD = "add"; static final String COMMAND_ADD = "add";
static final String COMMAND_REMOVE = "remove"; static final String COMMAND_REMOVE = "remove";
@@ -126,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;
} }
@@ -143,7 +148,7 @@ final class ChannelsReadSupport {
String sql = """ String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number 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
"""; """;
@@ -152,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();
@@ -204,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;
} }
@@ -218,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)) {
@@ -226,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()) {
@@ -292,16 +309,43 @@ 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) {
@@ -589,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;
@@ -143,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);
@@ -167,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);
}
}
}
}
@@ -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;
@@ -127,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<>();
@@ -158,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; }
@@ -173,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; }
}
@@ -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.5.1 client.version=1.5.35
server.version=1.4.5 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"
+22 -3
View File
@@ -88,6 +88,9 @@
- `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` - `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error` - `internal_error`
@@ -104,8 +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_CHANNEL_META (70)`скрытый технический снимок профиля канала - `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)`
@@ -135,6 +143,17 @@
5. **USER_PARAM (type=4)** 5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)` - `USER_PARAM_TEXT_TEXT (1)`
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-форматы для каналов и вложений ## 6. Практические payload-форматы для каналов и вложений
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. `AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
@@ -173,7 +192,7 @@
### Изменение профиля канала ### Изменение профиля канала
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`. Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
Текстовое содержимое body использует тот же формат полного снимка профиля: Текстовое содержимое body использует тот же формат полного снимка профиля:
+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` у пользователя сейчас нет активных онлайн-сессий.
+36 -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`) и загружаем полные списки.
@@ -192,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 },
@@ -248,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.
## Примечание ## Примечание
+1 -1
View File
@@ -9,7 +9,7 @@
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок: Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
- `msg_type=1` - `msg_type=1`
- `subType=70` - `subType=90`
- `TEXT_CHANNEL_META` - `TEXT_CHANNEL_META`
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
+31 -3
View File
@@ -1,6 +1,6 @@
# TEXT блоки (`type=1`, `version=1`) # TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования. TEXT-тип хранит сообщения, материалы и редактирования.
## Подтипы ## Подтипы
@@ -21,24 +21,52 @@ 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` до будущей реализации репостов.
6. `subType=70``TEXT_CHANNEL_META` 7. `subType=90``TEXT_CHANNEL_META`
- скрытый технический снимок профиля канала; - скрытый технический снимок профиля канала;
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием; - содержит 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). Подробная спецификация: [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`.
@@ -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` нет.
- Если нужно изменить смысл статуса, пишется новое статусное событие.
+1 -1
View File
@@ -5,7 +5,7 @@
## Блок ## Блок
- `msg_type = 1` - `msg_type = 1`
- `subType = 70` - `subType = 90`
- `msgVersion = 1` - `msgVersion = 1`
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст. - body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
+31
View File
@@ -1,5 +1,36 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 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 ## 2026-08-04 12:00:00 +0400
- Базовый коммит-ориентир: `391b18a`. - Базовый коммит-ориентир: `391b18a`.
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`. - Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
+7 -5
View File
@@ -17,15 +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. [15_TEXT_Attachments.md](./15_TEXT_Attachments.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` для видео и крупных изображений. Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) 10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
Скрытый `TEXT_CHANNEL_META` для профиля канала. Скрытый `TEXT_CHANNEL_META` для профиля канала.
10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) 11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`. Типы каналов и формат `CreateChannelBody`.
11. [02_Channel_Commands.md](./02_Channel_Commands.md) 12. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов. Команды в текстовых сообщениях каналов.
12. [CHANGELOG.md](./CHANGELOG.md) 13. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации. Журнал изменений документации.
## Смежная документация ## Смежная документация
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

+6 -1
View File
@@ -2,7 +2,10 @@
<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" />
@@ -37,7 +40,9 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
<div class="initial-splash__brand">Сияние</div> <div class="initial-splash__brand">Сияние</div>
</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>
+135 -4
View File
@@ -10,6 +10,7 @@ import {
handleIncomingCallInvite, handleIncomingCallInvite,
handleIncomingCallPush, handleIncomingCallPush,
handleIncomingCallSignal, handleIncomingCallSignal,
handleIncomingCallSignalViaSendSignal,
handleStopCallPush, handleStopCallPush,
setCallDebugReporter, setCallDebugReporter,
startDebugConnectionAsInitiator, startDebugConnectionAsInitiator,
@@ -150,6 +151,8 @@ 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 initialSplashEl = document.getElementById('initial-splash');
@@ -179,6 +182,8 @@ let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null; let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false; let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = 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',
@@ -202,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;
@@ -257,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}`;
@@ -889,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();
} }
@@ -913,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');
} }
@@ -924,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));
@@ -932,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);
@@ -949,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();
} }
@@ -1027,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');
@@ -1210,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 });
} }
} }
@@ -1313,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 || {};
@@ -1405,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();
@@ -286,7 +286,7 @@ export function openArweaveAttachmentManager({
historyOnly = false, historyOnly = false,
mode = 'attachment', mode = 'attachment',
historyPurpose = '', historyPurpose = '',
uploadTransport = 'arweave', uploadTransport = 'turbo',
turboKeySource = 'client', turboKeySource = 'client',
} = {}) { } = {}) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
@@ -303,7 +303,7 @@ export function openArweaveAttachmentManager({
let turboKeyChoices = []; let turboKeyChoices = [];
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key'; let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource); let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave'; let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'arweave' ? 'arweave' : 'turbo';
let selectedFile = null; let selectedFile = null;
let selectedSha256 = ''; let selectedSha256 = '';
let selectedPreviewEnabled = false; let selectedPreviewEnabled = false;
@@ -314,6 +314,9 @@ export function openArweaveAttachmentManager({
let balanceInfo = null; let balanceInfo = null;
let autoOpenedFileDialog = false; let autoOpenedFileDialog = false;
const isAvatarMode = String(mode || '') === 'avatar'; const isAvatarMode = String(mode || '') === 'avatar';
if (isAvatarMode && !String(uploadTransport || '').trim()) {
selectedUploadTransport = 'turbo';
}
const historyPurposeMode = String(historyPurpose || '').trim(); const historyPurposeMode = String(historyPurpose || '').trim();
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment'; const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean)); const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
@@ -589,17 +592,18 @@ export function openArweaveAttachmentManager({
<div class="modal" data-ar-attach-modal="true"> <div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card"> <div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3> <h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button> <button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button> <button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="history-top">Использовать журнал загрузок</button>'} <button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing-top">Использовать существующий в Arweave файл</button>'} <button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
</div> </div>
${turboMode ${turboMode
? ` ? `
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label> <label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select> <select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Файлы до 100 KB пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p> <p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
` `
: ` : `
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label> <label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
@@ -860,12 +864,14 @@ export function openArweaveAttachmentManager({
root.innerHTML = ` root.innerHTML = `
<div class="modal" data-ar-attach-modal="true"> <div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card"> <div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Существующий файл Arweave</h3> <h3 class="modal-title">${isAvatarMode ? 'Существующий аватар Arweave' : 'Существующий файл Arweave'}</h3>
<label class="meta-muted" for="ar-existing-txid">Transaction ID</label> <label class="meta-muted" for="ar-existing-txid">Transaction ID</label>
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" /> <input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
${isAvatarMode ? '' : `
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label> <label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" /> <input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
<label class="meta-muted" data-existing-preview-wrap="true" hidden> `}
<label class="meta-muted" data-existing-preview-wrap="true" ${isAvatarMode ? 'hidden' : 'hidden'}>
<input type="checkbox" data-existing-preview-toggle="true" /> <input type="checkbox" data-existing-preview-toggle="true" />
Добавить превью Добавить превью
</label> </label>
@@ -909,8 +915,8 @@ export function openArweaveAttachmentManager({
}); });
checkBtn?.addEventListener('click', async () => { checkBtn?.addEventListener('click', async () => {
const txId = String(txEl?.value || '').trim(); const txId = String(txEl?.value || '').trim();
const name = String(nameEl?.value || '').trim() || 'arweave-file'; const name = isAvatarMode ? 'Аватар' : (String(nameEl?.value || '').trim() || 'arweave-file');
const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked; const wantsPreview = !isAvatarMode && previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked;
const previewTxId = String(previewTxEl?.value || '').trim(); const previewTxId = String(previewTxEl?.value || '').trim();
if (!validateArweaveTxId(txId)) { if (!validateArweaveTxId(txId)) {
setText(errorEl, 'Некорректный Transaction ID Arweave.'); setText(errorEl, 'Некорректный Transaction ID Arweave.');
@@ -940,6 +946,7 @@ export function openArweaveAttachmentManager({
ar: txId, ar: txId,
preview, preview,
uploadedAtMs: Date.now(), uploadedAtMs: Date.now(),
purpose: isAvatarMode ? 'avatar' : 'attachment',
}); });
metaEl.innerHTML = ` metaEl.innerHTML = `
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div> <div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
@@ -950,7 +957,7 @@ export function openArweaveAttachmentManager({
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');
addBtn.className = 'primary-btn'; addBtn.className = 'primary-btn';
addBtn.type = 'button'; addBtn.type = 'button';
addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение'; addBtn.textContent = isAvatarMode ? 'Выбрать аватар' : (historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение');
addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode })); addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode }));
metaEl.append(addBtn); metaEl.append(addBtn);
} catch (error) { } catch (error) {
-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 });
+145 -18
View File
@@ -2,17 +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,
normalizeChannelDisplayName, normalizeChannelDisplayName,
validateChannelDisplayName, validateChannelDisplayName,
} from '../services/channel-name-rules.js'; } from '../services/channel-name-rules.js';
import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js'; import {
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.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 {
@@ -48,13 +54,38 @@ function renderAvatarPreview(slot, avatar, title) {
slot.append(wrap); 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 }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add'; screen.className = 'stack channels-screen channels-screen--add';
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Создать канал', title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
}), }),
); );
@@ -62,24 +93,28 @@ 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> <label for="channel-title">Как канал будет виден пользователям</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" /> <input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div> <div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div> <div id="channel-title-error" class="meta-muted inline-error"></div>
<div id="channel-avatar-preview"></div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
<label for="channel-description">Описание канала (необязательно)</label> <label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea> <textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div> <div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
@@ -96,7 +131,10 @@ export function render({ navigate }) {
const titleEl = form.querySelector('#channel-title'); 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 avatarPreviewEl = form.querySelector('#channel-avatar-preview');
const avatarStatusEl = form.querySelector('#channel-avatar-status');
const avatarBtn = form.querySelector('#channel-avatar-btn'); 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 titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter'); const titleCounterEl = form.querySelector('#channel-title-counter');
@@ -108,6 +146,58 @@ export function render({ navigate }) {
let submitInFlight = false; let submitInFlight = false;
let selectedAvatar = null; 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;
@@ -125,7 +215,7 @@ export function render({ navigate }) {
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название'); const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание'); const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code); nameErrorEl.textContent = '';
titleErrorEl.textContent = titleCheck.error; titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error; descriptionErrorEl.textContent = descriptionCheck.error;
@@ -134,7 +224,8 @@ export function render({ navigate }) {
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok; const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok; submitEl.disabled = submitInFlight || !ok;
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized); renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return { return {
ok, ok,
@@ -144,24 +235,46 @@ export function render({ navigate }) {
}; };
}; };
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); titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation); descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => { avatarBtn.addEventListener('click', async () => {
try { try {
selectedAvatar = await openArweaveAttachmentManager({ const nextAvatar = await openArweaveAttachmentManager({
login: state.session.login, login: state.session.login,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
mode: 'avatar', mode: 'avatar',
historyPurpose: 'avatar', historyPurpose: 'avatar',
}); });
if (nextAvatar?.ar) {
selectedAvatar = nextAvatar;
void refreshAvatarAvailability();
}
updateValidation(); updateValidation();
} catch (error) { } catch (error) {
errorEl.textContent = toUserMessage(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();
if (submitInFlight) return; if (submitInFlight) return;
@@ -174,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);
@@ -206,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;
} }
+1 -1
View File
@@ -157,7 +157,7 @@ export function render({ navigate }) {
const menu = controls.querySelector('[data-menu="true"]'); const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true; if (menu) menu.hidden = true;
window.alert( window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n' 'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. ' + 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.' + 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
); );
+135 -22
View File
@@ -24,6 +24,12 @@ 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();
@@ -209,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) {
@@ -417,10 +463,10 @@ function openBlockchainDetailsModal(details) {
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),
); );
} }
@@ -443,13 +489,22 @@ function renderDraftAttachments(container, attachments) {
}); });
} }
function openReplyModal({ onSubmit, navigate }) { 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> <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> <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>
@@ -486,7 +541,7 @@ function openReplyModal({ onSubmit, navigate }) {
const text = String(textEl?.value || '').trim(); const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) { if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.'; errorEl.textContent = emptyError;
return; return;
} }
@@ -499,7 +554,7 @@ function openReplyModal({ onSubmit, navigate }) {
close(); close();
} catch (error) { } catch (error) {
setBusy(false); setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.'); errorEl.textContent = toUserMessage(error, submitError);
} }
}); });
@@ -695,7 +750,6 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
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 : [];
@@ -703,11 +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); 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) {
@@ -720,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);
@@ -727,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';
@@ -755,6 +818,25 @@ 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() === 'удалено';
@@ -775,13 +857,19 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(deleted); card.append(deleted);
return card; return card;
} else { } else {
card.append(authorTile); card.append(headRow);
if (parsedText.attachments.length > 0) { if (parsedText.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedText.attachments, { card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
messageTimestampMs: node?.createdAtMs, 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'); const body = document.createElement('p');
body.className = 'channel-message-body'; body.className = 'channel-message-body';
body.textContent = parsedText.text; body.textContent = parsedText.text;
@@ -855,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';
@@ -872,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';
@@ -959,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);
@@ -1008,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 || ''}`;
@@ -1028,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';
@@ -1104,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);
@@ -1194,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');
@@ -1326,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()));
}); });
} }
@@ -1350,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) {
@@ -1358,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);
} }
+586 -37
View File
@@ -33,10 +33,28 @@ import {
makeShineMessageRoute, makeShineMessageRoute,
} from '../services/shine-routes.js'; } from '../services/shine-routes.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_REPOST = 50;
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 DIARY_CHANNEL_NAME = 'diary';
const DIARY_CHANNEL_DISPLAY_NAME = 'Дневник';
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
@@ -287,6 +305,82 @@ function resolveMessageTimestampMs(message) {
); );
} }
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 isEntrypointSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT;
}
function isDiarySelector(selector) {
return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME;
}
function isStatusActionSubType(msgSubType) {
return 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,
]).has(Number(msgSubType || 0));
}
function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) {
const status = Number(statusSubType || 0);
const target = Number(targetMsgSubType || 0);
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' };
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' };
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' };
if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' };
if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' };
if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' };
if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' };
if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' };
if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' };
return { label: 'Действие' };
}
function getStatusActionOptionsForTarget(targetMsgSubType) {
switch (Number(targetMsgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE:
return [
{ subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' },
{ subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' },
];
case MSG_SUBTYPE_TEXT_SERVICE:
return [
{ subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' },
];
case MSG_SUBTYPE_TEXT_COURSE:
return [
{ subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' },
{ subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' },
{ subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' },
{ subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' },
{ subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' },
];
default:
return [];
}
}
function createChannelAvatarElement(channel, size = 72) { function createChannelAvatarElement(channel, size = 72) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar'; wrap.className = 'channel-profile-avatar';
@@ -303,7 +397,19 @@ function createChannelAvatarElement(channel, size = 72) {
return wrap; return wrap;
} }
function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) { function openChannelMetaDetailsModal({
title = 'Данные канала',
channel = {},
canEdit = false,
onEdit = null,
changedAtMs = 0,
} = {}) {
const avatarState = String(channel.avaAr || '').trim()
? 'Установлен'
: 'Не установлен';
const changedAtLabel = changedAtMs
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
: '—';
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="about-channel-modal"> <div class="modal" id="about-channel-modal">
@@ -313,9 +419,14 @@ function openChannelMetaDetailsModal({ title = 'Данные канала', chan
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''} ${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
</div> </div>
<div id="about-channel-avatar-slot"></div> <div id="about-channel-avatar-slot"></div>
<p><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}</strong></p> <div class="channel-meta-details-grid">
<p class="meta-muted">${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}</p> <span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<p class="meta-muted">${escapeHtml(channel.description || 'Описание не задано.')}</p> <span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '')}</strong>
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
</div>
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button> <button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
</div> </div>
</div> </div>
@@ -337,6 +448,7 @@ function openAboutChannelModal(channel, options = {}) {
channel, channel,
canEdit: options.canEdit === true, canEdit: options.canEdit === true,
onEdit: options.onEdit, onEdit: options.onEdit,
changedAtMs: Number(channel?.metaUpdatedAtMs || 0),
}); });
} }
@@ -541,13 +653,22 @@ function renderDraftAttachments(container, attachments) {
}); });
} }
function openReplyModal({ onSubmit, navigate }) { 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="reply-modal"> <div class="modal" id="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="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="reply-attachments"></div> <div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button> <button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="reply-error"></div> <div class="meta-muted inline-error" id="reply-error"></div>
@@ -584,7 +705,7 @@ function openReplyModal({ onSubmit, navigate }) {
const text = String(textEl?.value || '').trim(); const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) { if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.'; errorEl.textContent = emptyError;
return; return;
} }
@@ -597,7 +718,7 @@ function openReplyModal({ onSubmit, navigate }) {
close(); close();
} catch (error) { } catch (error) {
setBusy(false); setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.'); errorEl.textContent = toUserMessage(error, submitError);
} }
}); });
@@ -626,6 +747,191 @@ function openReplyModal({ onSubmit, navigate }) {
if (textEl) textEl.focus(); if (textEl) textEl.focus();
} }
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title || 'Новое действие')}</h3>
<p class="meta-muted">Если хотите, можете добавить комментарий</p>
<textarea id="channel-status-action-text" class="input" rows="5" maxlength="2000" placeholder="Комментарий"></textarea>
<div class="meta-muted inline-error" id="channel-status-action-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-action-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-status-action-submit" type="button">${escapeHtml(submitLabel || 'Сохранить')}</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-status-action-text');
const errorEl = root.querySelector('#channel-status-action-error');
const submitEl = root.querySelector('#channel-status-action-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
if (textEl) textEl.disabled = inFlight;
if (submitEl) {
submitEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : (submitLabel || 'Сохранить');
}
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-status-action-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(String(textEl?.value || '').trim());
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
const root = document.getElementById('modal-root');
const rows = (Array.isArray(options) ? options : [])
.map((item, index) => `
<button class="channel-menu-item channel-status-action-item" data-status-index="${index}" type="button">
${escapeHtml(item.label || 'Действие')}
</button>
`)
.join('');
root.innerHTML = `
<div class="modal" id="channel-status-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(targetLabel || 'Действия')}</h3>
<div class="stack">${rows}</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-menu-cancel" type="button">Закрыть</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-status-menu-cancel')?.addEventListener('click', close);
root.querySelectorAll('[data-status-index]').forEach((button) => {
button.addEventListener('click', async (event) => {
const idx = Number(event.currentTarget?.dataset?.statusIndex || -1);
const option = options[idx];
if (!option) return;
close();
await onSelect(option);
});
});
}
function sortPostsByTimeDesc(posts = []) {
return [...posts].sort((a, b) => {
const byTime = Number(b?.timestampMs || 0) - Number(a?.timestampMs || 0);
if (byTime !== 0) return byTime;
return Number(b?.messageRef?.blockNumber || 0) - Number(a?.messageRef?.blockNumber || 0);
});
}
function getEntrypointPosts(posts = []) {
return sortPostsByTimeDesc((Array.isArray(posts) ? posts : []).filter((post) => isEntrypointSubType(post?.msgSubType)));
}
function flashAndScrollToMessage(messageRef) {
const key = messageRefKey(messageRef);
if (!key) return false;
const cards = Array.from(document.querySelectorAll('.channel-message-card[data-message-key]'));
const target = cards.find((card) => card.dataset.messageKey === key);
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
return true;
}
function openEntrypointMenuModal({ onShowHistory }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-entrypoint-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">Оглавление канала</h3>
<div class="stack">
<button class="channel-menu-item channel-status-action-item" id="channel-entrypoint-history" type="button">Просмотреть историю изменений оглавления</button>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-entrypoint-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('#channel-entrypoint-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#channel-entrypoint-history')?.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onShowHistory === 'function') onShowHistory();
});
}
function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect }) {
const root = document.getElementById('modal-root');
const items = getEntrypointPosts(posts);
const cleanTitle = String(channelTitle || '').trim() || 'канала';
root.innerHTML = `
<div class="modal" id="channel-entrypoint-history-modal">
<div class="modal-card stack">
<h3 class="modal-title">История оглавления</h3>
<p class="meta-muted">Показаны все версии оглавления канала ${escapeHtml(cleanTitle)}</p>
<div class="entrypoint-history-list" id="entrypoint-history-list"></div>
<button class="secondary-btn" id="entrypoint-history-close" type="button">Закрыть</button>
</div>
</div>
`;
const list = root.querySelector('#entrypoint-history-list');
if (list) {
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty);
} else {
items.forEach((post) => {
const parsed = parseMessageAttachments(post.body);
const item = document.createElement('button');
item.type = 'button';
item.className = 'entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
`;
item.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onSelect === 'function') onSelect(post);
});
list.append(item);
});
}
}
root.querySelector('#entrypoint-history-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openRepostModal({ navigate, channels = [], onSubmit }) { function openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : []) const options = (Array.isArray(channels) ? channels : [])
@@ -709,7 +1015,16 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
<p class="meta-muted">${channelName}</p> <p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea> <textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="draft-attachments" id="channel-message-attachments"></div> <div class="draft-attachments" id="channel-message-attachments"></div>
<div class="channel-message-tools">
<select id="channel-message-type" class="input channel-message-type-select">
<option value="${10}">Пост</option>
<option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option>
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
</select>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button> <button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div> <div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button> <button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
@@ -720,6 +1035,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
`; `;
const textEl = root.querySelector('#channel-message-text'); const textEl = root.querySelector('#channel-message-text');
const typeEl = root.querySelector('#channel-message-type');
const attachmentsEl = root.querySelector('#channel-message-attachments'); const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error'); const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit'); const submitEl = root.querySelector('#channel-message-submit');
@@ -730,6 +1046,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
inFlight = !!busy; inFlight = !!busy;
submitEl.disabled = inFlight; submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight; if (textEl) textEl.disabled = inFlight;
if (typeEl) typeEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight); root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить'; submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
}; };
@@ -743,6 +1060,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
if (inFlight) return; if (inFlight) return;
const body = String(textEl?.value || '').trim(); const body = String(textEl?.value || '').trim();
const msgSubType = Number(typeEl?.value || 10);
if (!body && attachments.length === 0) { if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.'; errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return; return;
@@ -752,7 +1070,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
errorEl.textContent = ''; errorEl.textContent = '';
try { try {
await onSubmit(composeMessageWithAttachments(body, attachments)); await onSubmit({
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment)); attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close(); close();
} catch (error) { } catch (error) {
@@ -880,6 +1201,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
const hasRef = !!(messageBch && blockNumber != null && blockHash); const hasRef = !!(messageBch && blockNumber != null && blockHash);
const resolvedText = resolveMessageText(message); const resolvedText = resolveMessageText(message);
const msgSubType = Number(message?.msgSubType || 0);
const isStatusAction = isStatusActionSubType(msgSubType);
const messageRef = hasRef const messageRef = hasRef
? { ? {
blockchainName: messageBch, blockchainName: messageBch,
@@ -895,15 +1218,18 @@ function mapApiMessageToPost(message, selector, localNumber) {
return { return {
localNumber, localNumber,
authorLogin: message?.authorLogin || 'автор', authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'), body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')),
versionsTotal: Number(message?.versionsTotal || 1), versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [], versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0), likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0), repliesCount: Number(message?.repliesCount || 0),
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message), timestampMs: resolveMessageTimestampMs(message),
messageRef, messageRef,
rawMessage: message, rawMessage: message,
msgSubType: Number(message?.msgSubType || 0), msgSubType,
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction,
isStatusAction,
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber)) targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? { ? {
blockchainName: String(message.targetBlockchainName).trim(), blockchainName: String(message.targetBlockchainName).trim(),
@@ -911,6 +1237,11 @@ function mapApiMessageToPost(message, selector, localNumber) {
blockHash: normalizeMessageHash(message?.targetBlockHash), blockHash: normalizeMessageHash(message?.targetBlockHash),
} }
: null, : null,
targetMsgSubType: Number(message?.targetMsgSubType || 0),
targetText: String(message?.targetText || '').trim(),
targetAuthorLogin: String(message?.targetAuthorLogin || '').trim(),
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
reactionState: messageRef ? getMessageReactionState(messageRef) : '', reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
}; };
@@ -939,6 +1270,45 @@ async function loadFromApi(route, channelId) {
}; };
let selector = buildSelectorFromRoute(route, channelId); let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerBlockchainName && selector?.channelName && isDiarySelector(selector)) {
if (!isAuthorized) {
throw new Error('Дневник доступен только после входа.');
}
const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc');
const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
const posts = diaryMessages
.map((message, index) => mapApiMessageToPost(message, selector, index + 1))
.sort((a, b) => {
const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0);
if (byTime !== 0) return byTime;
const aNum = Number(a?.messageRef?.blockNumber || 0);
const bNum = Number(b?.messageRef?.blockNumber || 0);
return aNum - bNum;
})
.map((post, index) => ({ ...post, localNumber: index + 1 }));
return {
channel: {
name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME,
displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(),
displayName: 'Дневника',
description: String(diaryPayload?.channel?.channelDescription || '').trim(),
avaAr: '',
avaSha256: '',
avaSize: 0,
metaUpdatedAtMs: 0,
ownerName: currentSessionLogin,
},
posts,
metaEvents: [],
reverseChannelMissingWarning: '',
isOwnChannel: true,
isSubscribed: true,
isDiary: true,
selector,
};
}
if (selector?.ownerBlockchainName && selector?.channelName) { if (selector?.ownerBlockchainName && selector?.channelName) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
@@ -1181,15 +1551,14 @@ function renderChannelMetaEventCard(event) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card channel-system-event-card'; card.className = 'card channel-system-event-card';
const label = event.kind === 'created' const label = event.kind === 'created'
? `Создан канал ${event.title || event.name || ''}`.trim() ? 'Создан канал'
: 'Изменён профиль канала'; : 'Изменено описание канала';
card.innerHTML = ` card.innerHTML = `
<span>${escapeHtml(label)}</span> <span class="channel-system-event-card__label">${escapeHtml(label)}</span>
<small>${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')}</small>
`; `;
card.addEventListener('click', () => { card.addEventListener('click', () => {
openChannelMetaDetailsModal({ openChannelMetaDetailsModal({
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён', title: label,
channel: { channel: {
name: event.name, name: event.name,
displayTitle: event.title, displayTitle: event.title,
@@ -1199,6 +1568,7 @@ function renderChannelMetaEventCard(event) {
avaSize: event.avaSize, avaSize: event.avaSize,
ownerName: event.ownerName, ownerName: event.ownerName,
}, },
changedAtMs: event.timestampMs,
}); });
}); });
return card; return card;
@@ -1209,6 +1579,9 @@ function renderPostCard(post, {
selector, selector,
onToggleLike, onToggleLike,
onReply, onReply,
onRating,
onStatusAction,
onOpenEntrypointMenu,
onRepost, onRepost,
onShare, onShare,
onEdit, onEdit,
@@ -1217,6 +1590,8 @@ function renderPostCard(post, {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card stack channel-message-card'; card.className = 'card stack channel-message-card';
if (post.isRating) card.classList.add('is-rating');
if (selector && isDiarySelector(selector)) card.classList.add('is-diary-entry');
const authorTile = document.createElement('button'); const authorTile = document.createElement('button');
authorTile.type = 'button'; authorTile.type = 'button';
@@ -1226,9 +1601,13 @@ function renderPostCard(post, {
const authorBlock = document.createElement('div'); const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author'; authorBlock.className = 'channel-message-author';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
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 = post.authorLogin; loginEl.textContent = post.authorLogin;
@@ -1236,12 +1615,12 @@ function renderPostCard(post, {
const numberEl = document.createElement('span'); const numberEl = document.createElement('span');
numberEl.className = 'author-line-num'; numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`; numberEl.textContent = `· #${post.localNumber}`;
titleMain.append(loginEl, numberEl);
title.append(titleMain);
const timestamp = document.createElement('div'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—'; timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
title.append(loginEl, numberEl);
if (versionsTotal > 1) { if (versionsTotal > 1) {
const editedMarker = document.createElement('button'); const editedMarker = document.createElement('button');
editedMarker.type = 'button'; editedMarker.type = 'button';
@@ -1260,6 +1639,31 @@ function renderPostCard(post, {
} }
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
const typeMeta = getChannelMessageTypeMeta(post.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 onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
onStatusAction(post);
});
} else if (isEntrypointSubType(post.msgSubType) && typeof onOpenEntrypointMenu === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
onOpenEntrypointMenu(post);
});
} else {
typeButton.classList.add('is-static');
typeButton.disabled = true;
}
headRow.append(typeButton);
}
authorTile.addEventListener('click', (event) => { authorTile.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim(); const cleanLogin = String(post.authorLogin || '').trim();
@@ -1287,13 +1691,39 @@ function renderPostCard(post, {
card.append(deleted); card.append(deleted);
return card; return card;
} else { } else {
card.append(authorTile); card.append(headRow);
if (parsedBody.attachments.length > 0) { if (parsedBody.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedBody.attachments, { card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
messageTimestampMs: post.timestampMs, messageTimestampMs: post.timestampMs,
})); }));
} }
if (post.isRating) {
const ratingBadge = document.createElement('span');
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
ratingBadge.textContent = 'Оценка';
card.append(ratingBadge);
}
if (post.isStatusAction && selector && isDiarySelector(selector)) {
const statusBadge = document.createElement('span');
statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status';
statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label;
card.append(statusBadge);
if (post.targetText || post.targetAuthorLogin) {
const targetPreview = document.createElement('div');
targetPreview.className = 'channel-message-target-preview';
const targetType = getChannelMessageTypeMeta(post.targetMsgSubType)?.label || 'Материал';
const targetParsed = parseDmTechBlocks(String(post.targetText || '').trim());
const targetText = String(targetParsed.displayText || targetParsed.visibleText || '').trim();
const targetAuthor = String(post.targetAuthorLogin || '').trim();
targetPreview.innerHTML = `
<strong>${escapeHtml(targetType)}</strong>
<span>${escapeHtml(targetAuthor || 'автор')}</span>
<p>${escapeHtml(targetText || 'Без текста')}</p>
`;
card.append(targetPreview);
}
}
const body = document.createElement('p'); const body = document.createElement('p');
body.className = 'channel-message-body'; body.className = 'channel-message-body';
body.textContent = parsedBody.text; body.textContent = parsedBody.text;
@@ -1357,9 +1787,27 @@ function renderPostCard(post, {
onSubmit: async (text) => onReply(post.messageRef, text), onSubmit: async (text) => onReply(post.messageRef, text),
}); });
}); });
const ratingButton = document.createElement('button');
ratingButton.type = 'button';
ratingButton.className = 'channel-action-item channel-action-rating';
ratingButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оценка</span>
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
`;
setActionTitle(ratingButton, 'Оценка');
ratingButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
mode: 'rating',
onSubmit: async (text) => onRating(post.messageRef, text),
});
});
// Репосты временно отключены до будущей реализации. // Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton); actions.append(likeButton, replyButton, ratingButton);
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
@@ -1377,7 +1825,7 @@ function renderPostCard(post, {
}); });
actions.append(shareButton); actions.append(shareButton);
if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button'); const originalBtn = document.createElement('button');
originalBtn.type = 'button'; originalBtn.type = 'button';
originalBtn.className = 'channel-action-item'; originalBtn.className = 'channel-action-item';
@@ -1506,6 +1954,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
selector: channelData.selector, selector: channelData.selector,
onToggleLike: handlers.onToggleLike, onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply, onReply: handlers.onReply,
onRating: handlers.onRating,
onStatusAction: handlers.onStatusAction,
onOpenEntrypointMenu: handlers.onOpenEntrypointMenu,
onRepost: handlers.onRepost, onRepost: handlers.onRepost,
onShare: handlers.onShare, onShare: handlers.onShare,
onEdit: handlers.onEdit, onEdit: handlers.onEdit,
@@ -1519,7 +1970,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
} 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 = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
feed.append(empty); feed.append(empty);
} }
@@ -1533,7 +1986,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам'; backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list')); backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) { if (channelData.isDiary) {
screen.append(feed, backButton);
} else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton); screen.append(feed, addMessageButton);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(actionButton, feed, backButton); screen.append(actionButton, feed, backButton);
@@ -1541,7 +1996,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton); screen.append(feed, backButton);
} }
applyPendingScroll(screen, routeKey, channelData.isOwnChannel); applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
return () => { return () => {
// noop // noop
}; };
@@ -1555,7 +2010,7 @@ function renderSkeleton(screen) {
return wrap; return wrap;
} }
export function render({ navigate, route }) { export function render({ navigate, route, chrome }) {
const channelId = route.params.channelId || ''; const channelId = route.params.channelId || '';
const routeSelector = buildSelectorFromRoute(route, channelId); const routeSelector = buildSelectorFromRoute(route, channelId);
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`; const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
@@ -1582,13 +2037,21 @@ export function render({ navigate, route }) {
const header = renderHeader({ const header = renderHeader({
title: '', title: '',
leftAction: { label: '<', onClick: () => navigateBack() }, leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Канал: ...', onClick: () => {} }], rightActions: [
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
],
}); });
const channelHeaderButton = header.querySelector('.header-actions .icon-btn'); const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
if (channelHeaderButton) { if (channelHeaderButton) {
channelHeaderButton.classList.add('channel-header-route-btn');
channelHeaderButton.disabled = true; channelHeaderButton.disabled = true;
} }
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
chrome?.setTopbar(header);
const rerender = () => { const rerender = () => {
const current = document.querySelector('section.channels-screen--channel'); const current = document.querySelector('section.channels-screen--channel');
@@ -1651,6 +2114,47 @@ export function render({ navigate, route }) {
rerender(); rerender();
}; };
const onRating = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Оценка отправлена');
rerender();
};
const onStatusAction = async (post) => {
const options = getStatusActionOptionsForTarget(post?.msgSubType);
const typeMeta = getChannelMessageTypeMeta(post?.msgSubType);
if (!options.length || !typeMeta || !post?.messageRef) return;
openStatusActionMenuModal({
targetLabel: typeMeta.label,
options,
onSelect: async (option) => {
openStatusActionCommentModal({
title: option.modalTitle,
submitLabel: option.label,
onSubmit: async (text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockStatusAction({
login,
storagePwd,
message: post.messageRef,
text,
statusSubType: option.subType,
});
softHaptic(14);
showToast(`${option.label} сохранено`);
rerender();
},
});
},
});
};
const loadOwnedChannelsForRepost = async (login) => { const loadOwnedChannelsForRepost = async (login) => {
const feed = await authService.listSubscriptionsFeed(login, 1000); const feed = await authService.listSubscriptionsFeed(login, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
@@ -1723,7 +2227,7 @@ export function render({ navigate, route }) {
} }
}; };
const onAddPost = async (bodyText) => { const onAddPost = async (bodyText, msgSubType = 10) => {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.'); throw new Error('Идентификатор канала не готов.');
@@ -1734,6 +2238,7 @@ export function render({ navigate, route }) {
storagePwd, storagePwd,
channel: activeSelector, channel: activeSelector,
text: bodyText, text: bodyText,
msgSubType,
}); });
pendingScrollByRoute.set(routeKey, '__LAST__'); pendingScrollByRoute.set(routeKey, '__LAST__');
@@ -1744,7 +2249,8 @@ export function render({ navigate, route }) {
const onEditPost = async (messageRef, text) => { const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { const isDiaryEdit = isDiarySelector(activeSelector);
if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) {
throw new Error('Идентификатор канала не готов.'); throw new Error('Идентификатор канала не готов.');
} }
await authService.addBlockEditMessage({ await authService.addBlockEditMessage({
@@ -1752,8 +2258,8 @@ export function render({ navigate, route }) {
storagePwd, storagePwd,
message: messageRef, message: messageRef,
text, text,
isChannelPost: true, isChannelPost: !isDiaryEdit,
channel: activeSelector, channel: isDiaryEdit ? null : activeSelector,
}); });
softHaptic(12); softHaptic(12);
showToast('Сообщение обновлено'); showToast('Сообщение обновлено');
@@ -1779,7 +2285,6 @@ export function render({ navigate, route }) {
rerender(); rerender();
}; };
screen.append(header);
screen.append(statusBox); screen.append(statusBox);
const skeleton = renderSkeleton(screen); const skeleton = renderSkeleton(screen);
@@ -1791,13 +2296,25 @@ export function render({ navigate, route }) {
const apiData = await loadFromApi(route, channelId); const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null; activeSelector = apiData?.selector || null;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel'; const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
openEntrypointHistoryModal({
channelTitle: titleLabel,
posts: apiData?.posts,
onSelect: (post) => {
if (!flashAndScrollToMessage(post?.messageRef)) {
showToast('Не удалось найти запись оглавления в ленте');
}
},
});
};
if (channelHeaderButton) { if (channelHeaderButton) {
channelHeaderButton.textContent = titleLabel; channelHeaderButton.textContent = titleLabel;
channelHeaderButton.disabled = false; channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => { channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel, { openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true, canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
onEdit: () => openEditChannelModal({ onEdit: () => openEditChannelModal({
channel: apiData.channel, channel: apiData.channel,
onSave: onEditChannelMeta, onSave: onEditChannelMeta,
@@ -1805,15 +2322,26 @@ export function render({ navigate, route }) {
}); });
}; };
} }
if (channelEntrypointButton) {
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
channelEntrypointButton.hidden = !canShowEntrypointButton;
channelEntrypointButton.disabled = !canShowEntrypointButton;
channelEntrypointButton.onclick = () => {
const latestEntrypoint = entrypointPosts[0];
if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) {
showToast('Не удалось найти актуальное оглавление');
}
};
}
skeleton.remove(); skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onAddMessage: () => { onAddMessage: () => {
openAddMessageModal({ openAddMessageModal({
channelName: apiData?.channel?.name || '', channelName: apiData?.channel?.name || '',
navigate, navigate,
onSubmit: async (bodyText) => { onSubmit: async ({ text: bodyText, msgSubType }) => {
try { try {
await onAddPost(bodyText); await onAddPost(bodyText, msgSubType);
showStatus(''); showStatus('');
} catch (error) { } catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
@@ -1837,6 +2365,27 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.')); throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
} }
}, },
onRating: async (messageRef, text) => {
try {
await onRating(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отправить оценку.'));
}
},
onStatusAction: async (post) => {
try {
await onStatusAction(post);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось записать действие.'));
}
},
onOpenEntrypointMenu: () => {
openEntrypointMenuModal({
onShowHistory: openEntrypointHistory,
});
},
onRepost: async (messageRef) => { onRepost: async (messageRef) => {
try { try {
await onRepost(messageRef); await onRepost(messageRef);
+78 -22
View File
@@ -14,6 +14,7 @@ import {
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 { 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: 'Каналы' };
@@ -21,10 +22,14 @@ 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) { function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text); const parsed = parseMessageAttachments(text);
return String(parsed.text || '').trim() || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний'); const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
} }
function isChannelsDemoMode() { function isChannelsDemoMode() {
@@ -698,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));
@@ -1092,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 }) {
@@ -1126,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);
@@ -1151,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');
@@ -1245,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();
+5
View File
@@ -4,6 +4,7 @@ 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 {
@@ -251,6 +252,10 @@ export function render({ navigate }) {
return isFree && className === 'free'; 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');
+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;
+12 -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([
@@ -90,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' } };
} }
@@ -100,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',
@@ -118,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: '',
@@ -131,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',
+19 -1
View File
@@ -278,12 +278,25 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
openAttachmentViewer({ item, url, kind }); 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') { if (kind === 'image') {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'message-attachment-media'; img.className = 'message-attachment-media';
img.src = url; img.src = url;
img.alt = item.name; img.alt = item.name;
img.loading = 'lazy'; img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
frame.append(img); frame.append(img);
} else if (previewUrl) { } else if (previewUrl) {
@@ -292,6 +305,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
img.src = previewUrl; img.src = previewUrl;
img.alt = `${item.name} preview`; img.alt = `${item.name} preview`;
img.loading = 'lazy'; img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span'); const play = document.createElement('span');
play.className = 'message-attachment-play'; play.className = 'message-attachment-play';
@@ -351,6 +365,7 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
next.setAttribute('aria-label', 'Следующее вложение'); next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div'); const counter = document.createElement('div');
counter.className = 'message-attachment-counter'; counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
const render = () => { const render = () => {
stopVideos(wrap); stopVideos(wrap);
@@ -358,6 +373,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
const kind = getAttachmentKind(item); const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar }); const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.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.dataset.unavailable = '0';
slide.replaceChildren(); slide.replaceChildren();
slide.append(kind === 'file' slide.append(kind === 'file'
@@ -365,7 +382,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl })); : createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0; prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1; next.hidden = items.length <= 1 || index >= items.length - 1;
counter.textContent = `${index + 1} из ${items.length}`; counter.hidden = items.length <= 1;
counter.textContent = items.length > 1 ? `${index + 1} из ${items.length}` : '';
}; };
const move = (delta) => { const move = (delta) => {
+166 -7
View File
@@ -47,14 +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_CHANNEL_META = 70; 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;
@@ -563,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,
@@ -1427,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(),
@@ -1768,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');
@@ -2211,14 +2359,25 @@ export class AuthService {
}; };
} }
async addBlockTextPost({ login, channel, text, storagePwd }) { async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
const cleanLogin = (login || '').trim(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login'); if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim(); 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 selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim(); const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber); const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`; const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
@@ -2238,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,
}); });
@@ -2631,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',
+27 -13
View File
@@ -10,7 +10,7 @@ function decodeRoutePart(value = '') {
} }
} }
const SHINE_ROUTE_PREFIX = 'SHiNE.'; const SHINE_ROUTE_SEGMENT = 'SHiNE';
export function normalizeLogin(value = '') { export function normalizeLogin(value = '') {
return String(value || '').trim().replace(/^@+/, ''); return String(value || '').trim().replace(/^@+/, '');
@@ -25,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_ROUTE_PREFIX}${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_ROUTE_PREFIX}${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_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/owned`; if (normalizedScope === 'owned') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/following`; if (normalizedScope === 'following') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/following`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels`; return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels`;
} }
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) { export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
@@ -47,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_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`; if (msgNo) return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_PREFIX}${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_ROUTE_PREFIX}${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_ROUTE_PREFIX.toLowerCase())) return ''; if (!parts.length) return null;
return normalizeLogin(decodeRoutePart(raw.slice(SHINE_ROUTE_PREFIX.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')
+8 -3
View File
@@ -2,13 +2,12 @@
"name": "СИЯНИЕ", "name": "СИЯНИЕ",
"short_name": "СИЯНИЕ", "short_name": "СИЯНИЕ",
"start_url": "./index.html", "start_url": "./index.html",
"display": "fullscreen", "display": "standalone",
"display_override": [ "display_override": [
"fullscreen",
"standalone", "standalone",
"minimal-ui" "minimal-ui"
], ],
"orientation": "any", "orientation": "portrait",
"background_color": "#0b1020", "background_color": "#0b1020",
"theme_color": "#0b1020", "theme_color": "#0b1020",
"icons": [ "icons": [
@@ -21,6 +20,12 @@
"src": "./img/logo.jpg", "src": "./img/logo.jpg",
"sizes": "512x512", "sizes": "512x512",
"type": "image/jpeg" "type": "image/jpeg"
},
{
"src": "./img/shine-logo-transparent-final.png",
"sizes": "1280x1280",
"type": "image/png",
"purpose": "any maskable"
} }
] ]
} }
File diff suppressed because it is too large Load Diff
+58 -11
View File
@@ -4,6 +4,7 @@ body {
background: #05070A; background: #05070A;
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
overflow: hidden;
} }
body::before { body::before {
@@ -22,9 +23,17 @@ body::before {
.app-shell { .app-shell {
width: min(100vw, 430px); width: min(100vw, 430px);
height: 100dvh; height: 100vh;
position: relative; height: 100svh;
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
--call-minimized-bar-height: 0px; --call-minimized-bar-height: 0px;
--topbar-height: 0px;
--composer-height: 0px;
--toolbar-height: 78px;
--keyboard-offset: 0px;
background: transparent; background: transparent;
border-left: 1px solid rgba(211, 170, 86, 0.2); border-left: 1px solid rgba(211, 170, 86, 0.2);
border-right: 1px solid rgba(211, 170, 86, 0.2); border-right: 1px solid rgba(211, 170, 86, 0.2);
@@ -33,6 +42,8 @@ body::before {
} }
.screen-content, .screen-content,
.topbar-slot,
.composer-slot,
.toolbar-slot, .toolbar-slot,
.connection-retry-banner { .connection-retry-banner {
z-index: 1; z-index: 1;
@@ -40,15 +51,16 @@ body::before {
.screen-content { .screen-content {
position: absolute; position: absolute;
top: var(--call-minimized-bar-height, 0px); top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
left: 0; left: 0;
right: 0; right: 0;
bottom: 74px; bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
overflow-y: auto; overflow-y: auto;
padding: 14px 14px 24px; padding: 14px 14px 24px;
} }
.screen-content.no-app-chrome { .screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0; bottom: 0;
padding-bottom: calc(24px + env(safe-area-inset-bottom)); padding-bottom: calc(24px + env(safe-area-inset-bottom));
} }
@@ -62,26 +74,61 @@ body::before {
padding: 0; padding: 0;
} }
.topbar-slot[hidden],
.composer-slot[hidden] {
display: none;
}
.topbar-slot {
position: absolute;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
padding: 0 12px;
pointer-events: none;
}
.topbar-slot > * {
pointer-events: auto;
}
.composer-slot {
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px 8px;
pointer-events: none;
}
.composer-slot > * {
pointer-events: auto;
}
.toolbar-slot { .toolbar-slot {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%); background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
transition: opacity 0.18s ease, transform 0.18s ease; transition: opacity 0.18s ease, transform 0.18s ease;
} }
body.chat-keyboard-open .screen-content { body.chat-keyboard-open .screen-content {
bottom: 0; bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)));
padding-bottom: calc(14px + env(safe-area-inset-bottom)); padding-bottom: calc(14px + env(safe-area-inset-bottom));
} }
body.chat-keyboard-open .composer-slot {
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px));
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .toolbar-slot { body.chat-keyboard-open .toolbar-slot {
opacity: 0; opacity: 1;
pointer-events: none; pointer-events: auto;
transform: translateY(calc(100% + env(safe-area-inset-bottom))); transform: none;
} }
.connection-retry-banner { .connection-retry-banner {
@@ -125,8 +172,8 @@ body.chat-keyboard-open .toolbar-slot {
@media (min-width: 900px) { @media (min-width: 900px) {
.app-shell { .app-shell {
margin: 16px 0; top: 16px;
height: calc(100dvh - 32px); height: calc(100svh - 32px);
border-radius: 24px; border-radius: 24px;
} }
} }
+4
View File
@@ -26,7 +26,11 @@ html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%;
height: 100%;
min-height: 100%; min-height: 100%;
overflow: hidden;
overscroll-behavior: none;
background: background:
radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%), radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%),
radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%), radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%),
@@ -0,0 +1,60 @@
# Черновик CHANNEL_MEMBERSHIP для каналов
## Статус документа
Этот файл является отдельным отложенным черновиком.
Важно:
- тема временно вынесена из основной первой итерации;
- в текущую реализацию не входит;
- документ нужен, чтобы не потерять уже согласованные мысли и вернуться к ним позже.
## Зачем вынесено отдельно
`CHANNEL_MEMBERSHIP` пока решено не делать вместе с `TEXT` и `STATUS_ACTION`, чтобы:
- не перегружать текущую реализацию;
- не смешивать две разные задачи;
- спокойно завершить первую итерацию по контенту и статусам;
- вернуться к membership позже отдельным этапом.
## Отложенная схема ссылок
На текущий момент в отложенный черновик заносится такая схема:
- `JOIN_REQUEST` ссылается на root-блок канала;
- `JOIN_ACCEPTED` ссылается на `JOIN_REQUEST`;
- `LEFT` ссылается на root-блок канала;
- `REMOVED` ссылается на последнее membership-событие этого участника в этом канале, лучше всего на `JOIN_ACCEPTED`.
## Предварительные подтипы
Если тема будет возвращена в реализацию, предварительно рассматриваются:
- `subType=10``CHANNEL_JOIN_REQUEST`
- `subType=20``CHANNEL_JOIN_ACCEPTED`
- `subType=30``CHANNEL_LEFT`
- `subType=40``CHANNEL_REMOVED`
## Что ещё нужно будет отдельно решить позже
Перед возвратом к теме нужно будет отдельно утвердить:
- точный байтовый формат `CHANNEL_MEMBERSHIP`;
- в чьём блокчейне пишутся membership-события;
- политику прав доступа:
- кто может писать `JOIN_ACCEPTED`;
- кто может писать `REMOVED`;
- кто и как подтверждает актуальный состав канала;
- как сервер строит read-model текущего состава канала;
- как UI показывает pending-заявки, принятых участников, вышедших и исключённых.
## Краткий итог
`CHANNEL_MEMBERSHIP` не отменён, а именно отложен.
Следующий рекомендуемый шаг:
- сначала завершить первую итерацию `TEXT + STATUS_ACTION`;
- потом отдельным этапом вернуться к membership-логике каналов.
@@ -0,0 +1,723 @@
# Черновик дополнений каналов и типов сообщений SHiNE
## Статус документа
Этот файл является рабочим черновиком первой итерации.
Важно:
- это не финальная спецификация;
- формат блокчейна этим документом пока не меняется автоматически;
- документ фиксирует согласованную на текущий момент концепцию;
- в первую итерацию входят только `TEXT` и `STATUS_ACTION`;
- всё, что касается `CHANNEL_MEMBERSHIP`, вынесено в отдельный отложенный черновик:
- `Черновик_CHANNEL_MEMBERSHIP_для_каналов.md`
## Цель
Нужно расширить текущую модель каналов SHiNE так, чтобы канал был не только лентой обычных постов, но и пространством:
- материалов;
- упражнений;
- услуг / процедур;
- курсов;
- входных страниц каналов;
- истории действий пользователей по этим материалам.
При этом важно:
- не ломать существующий блокчейн без отдельного согласованного шага;
- минимально менять верхнеуровневые `type`;
- по возможности переиспользовать уже существующий текстовый тип сообщений;
- отделять собственно контент от событий состояния.
## Базовая идея первой итерации
На текущий момент согласована следующая модель:
- старый `type=1`, который раньше назывался `TEXT`, по коду можно не менять;
- в документации и логике его можно трактовать шире как основной контентный тип сообщений;
- все новые смысловые материалы каналов добавляются как новые `subType` внутри `type=1`;
- отдельным верхнеуровневым `type` в первой итерации становится только:
- `type=5` — действия пользователя по материалам.
Иначе говоря:
- `type=1` = сообщения и материалы;
- `type=5` = статусные действия пользователя;
- `type=6` = тема вынесена в отдельный отложенный черновик и в первую итерацию не входит.
## Текущие старые верхнеуровневые типы
Они сохраняются:
- `type=0``TECH`
- `type=1``TEXT`
- `type=2``REACTION`
- `type=3``CONNECTION`
- `type=4``USER_PARAM`
## Верхнеуровневые типы первой итерации
Используются:
- `0``TECH`
- `1``TEXT`
- `2``REACTION`
- `3``CONNECTION`
- `4``USER_PARAM`
- `5``STATUS_ACTION`
Отдельно:
- `6``CHANNEL_MEMBERSHIP`
- зарезервирован как следующая тема;
- вынесен в отдельный черновик;
- в текущую реализацию не входит.
## Подтипы внутри `type=1`
Согласованная таблица первой итерации:
- `subType=10``TEXT_POST`
- `subType=11``TEXT_EDIT_POST`
- `subType=20``TEXT_REPLY`
- `subType=21``TEXT_EDIT_REPLY`
- `subType=30``TEXT_RATING`
- `subType=50``TEXT_REPOST`
- `subType=90``TEXT_CHANNEL_META`
- `subType=100``TEXT_ENTRYPOINT`
- `subType=110``TEXT_EXERCISE`
- `subType=120``TEXT_SERVICE`
- `subType=130``TEXT_COURSE`
## Принцип форматов внутри `type=1`
### Line-based сообщения
Следующие подтипы считаются line-based и используют тот же body, что и обычный `TEXT_POST`:
- `TEXT_POST`
- `TEXT_REPOST`
- `TEXT_CHANNEL_META`
- `TEXT_ENTRYPOINT`
- `TEXT_EXERCISE`
- `TEXT_SERVICE`
- `TEXT_COURSE`
### Target-based сообщения
Следующие подтипы считаются target-based:
- `TEXT_REPLY`
- `TEXT_EDIT_REPLY`
- `TEXT_RATING`
### Edit для `TEXT`
В первой итерации согласовано оставить в коде два технических edit-подтипа:
- `TEXT_EDIT_POST`
- edit для сообщений линии канала;
- `TEXT_EDIT_REPLY`
- edit для reply-сообщений.
То есть в документации больше не используется старое упрощённое описание “один общий `TEXT_EDIT`”.
## Смысл подтипов `type=1`
### `TEXT_POST`
Обычный текстовый пост в канале.
### `TEXT_EDIT_POST`
Редактирование line-based текстового сообщения канала.
Принцип:
- edit всегда ссылается на оригинальный блок;
- edit не должен ссылаться на предыдущий edit;
- фактический тип и правила берутся из оригинального сообщения.
### `TEXT_REPLY`
Обычный ответ / комментарий на сообщение.
Важно:
- reply остаётся единым;
- отвечать можно на `post`, `rating`, `exercise`, `service`, `course`, `entrypoint`, `status_action`;
- reply сам является target-based сообщением.
### `TEXT_EDIT_REPLY`
Редактирование reply-сообщения.
### `TEXT_RATING`
Текстовый отзыв / мнение / оценка на конкретный блок.
Смысл:
- это не line-based пост;
- это target-based сообщение-отзыв;
- оно всегда ссылается на конкретный блок, который оценивает;
- оценку пользователя как отдельную сущность в эту итерацию не включаем.
### `TEXT_REPOST`
Отложенная будущая заготовка.
На текущем этапе:
- код зарезервирован;
- бизнес-логика не входит в первую итерацию;
- детальная реализация может быть возвращена позже.
### `TEXT_CHANNEL_META`
Специальное скрытое сообщение метаданных канала.
Через него задаются:
- красивое имя канала;
- аватар;
- описание;
- и другие общие channel meta.
Оно:
- не является обычным пользовательским сообщением;
- не должно показываться в ленте как обычный пост;
- должно парситься по отдельным техническим правилам.
### `TEXT_ENTRYPOINT`
Входная / главная страница канала.
Смысл:
- это не отдельный верхнеуровневый тип;
- это специальный line-based текстовый материал;
- он служит входной страницей канала;
- он может объяснять структуру канала, давать ссылки, вводить человека в тему.
Принцип:
- `TEXT_ENTRYPOINT` не редактируется через `TEXT_EDIT_POST`;
- новая версия создаётся новым сообщением `TEXT_ENTRYPOINT`;
- если в канале несколько `entrypoint`, актуальным считается последний.
### `TEXT_EXERCISE`
Упражнение или комплекс упражнений.
Смысл:
- материал, который можно выполнять много раз;
- материал, который можно выучить;
- по нему удобно строить статистику выполнений и освоения.
### `TEXT_SERVICE`
Услуга / процедура.
Смысл:
- материал, который пользователь проходит;
- обычно не “учится выполнять”, а именно получает / проходит;
- прохождение фиксируется отдельным `STATUS_ACTION`.
### `TEXT_COURSE`
Курс.
Смысл:
- материал, по которому есть путь обучения;
- пользователь может заинтересоваться, начать, учиться, завершить, бросить.
## Канал `0`
Согласована новая трактовка канала `0`.
Канал `0` становится:
- обычным каналом публикаций пользователя по умолчанию;
- местом его основной ленты;
- местом его главной страницы, если там есть `entrypoint`.
### Что это значит
Если у пользователя в канале `0` существует `TEXT_ENTRYPOINT`, тогда:
- ссылка `SHiNE/<login>` открывает именно этот последний `entrypoint`.
Если `entrypoint` в канале `0` нет, тогда:
- `SHiNE/<login>` открывает обычную ленту публикаций канала `0`.
## Правила открытия каналов и ссылок
Согласована следующая логика:
- `SHiNE/<login>`
- главная страница пользователя, то есть канал `0`;
- `SHiNE/<login>/<channel>`
- основная ссылка канала.
### Как открывается `SHiNE/<login>/<channel>`
Если у канала есть хотя бы один `TEXT_ENTRYPOINT`, тогда:
- по умолчанию открывается последний актуальный `entrypoint`;
- UI показывает, сколько после него было новых сообщений;
- UI показывает кнопку перехода в конец канала.
Если `entrypoint` нет:
- открывается обычная лента канала.
Для подписанного пользователя:
- логичнее вести его в место новых непрочитанных сообщений;
- а не всегда принудительно открывать entrypoint;
- при этом сверху можно показывать кнопку `Открыть entrypoint`.
### Ссылки на конкретные сообщения
Остаются обычные формы:
- `SHiNE/<login>/<channel>/<messageNumber>`
- `SHiNE/<login>/<channel>/<messageNumber>/<hash>`
Если нужно показать старую конкретную версию `entrypoint`, даётся ссылка именно на номер нужного сообщения.
## Новый тип `STATUS_ACTION`
`type=5` вводится для действий пользователя по контенту.
## Принцип `STATUS_ACTION`
Согласовано:
- `STATUS_ACTION` всегда является target-based сообщением;
- статусное действие всегда ссылается на конкретный блок-материал;
- это не обычный пост в канале;
- из таких сообщений можно собирать виртуальную ленту пользователя.
### Как трактуется текст в `STATUS_ACTION`
Основной смысл блока задаётся самим статусным событием.
Текст внутри такого блока:
- это комментарий пользователя к действию, если он есть;
- не является главным смыслом записи;
- служит пояснением.
Примеры:
- “Начал сегодня”
- “Решил пройти серьёзно”
- “Сделал это после практики”
- “Выучил базовый комплекс”
## Подтипы `STATUS_ACTION`
Предлагаемая таблица:
- `subType=10``STATUS_DONE_ONCE`
- `subType=20``STATUS_INTERESTED`
- `subType=30``STATUS_STARTED`
- `subType=40``STATUS_IN_STUDY`
- `subType=50``STATUS_COMPLETED`
- `subType=60``STATUS_ABANDONED`
- `subType=70``STATUS_LEARNED`
- `subType=80``STATUS_CONFIRMED`
## Смысл подтипов `STATUS_ACTION`
### `STATUS_DONE_ONCE`
Факт одного выполнения / прохождения.
Это накопительное событие.
### `STATUS_INTERESTED`
Пользователя заинтересовал материал.
### `STATUS_STARTED`
Пользователь начал.
Это начальный статус процесса, но ещё не “устойчивое обучение”.
### `STATUS_IN_STUDY`
Пользователь уже полноценно находится в обучении.
Смысл:
- не просто попробовал;
- а реально учится;
- это отдельный статус, более сильный, чем `started`.
### `STATUS_COMPLETED`
Пользователь завершил курс / прохождение.
### `STATUS_ABANDONED`
Пользователь бросил.
### `STATUS_LEARNED`
Пользователь выучил упражнение или комплекс и знает, как его делать.
Это не то же самое, что “сделал один раз”.
### `STATUS_CONFIRMED`
Подтверждение чужого status-события.
Важно:
- подтверждается не курс вообще;
- не упражнение вообще;
- а конкретный `STATUS_ACTION` конкретного пользователя.
## Кто ставит статусы
Согласовано правило:
- любой базовый статус ставит сам пользователь от своего имени;
- другие люди не ставят статус за него;
- другие люди могут только подтверждать его status-событие через `STATUS_CONFIRMED`.
То есть:
- `INTERESTED`, `STARTED`, `IN_STUDY`, `COMPLETED`, `ABANDONED`, `LEARNED`, `DONE_ONCE`
- ставит сам пользователь;
- `CONFIRMED`
- ставят другие люди на конкретный статусный блок.
### Вес подтверждений
В базовой версии веса подтверждений не вводятся.
Но в будущем можно добавить:
- более значимое подтверждение от создателя курса;
- более значимое подтверждение от создателя упражнения;
- весовые коэффициенты от близких / доверенных людей;
- слабые подтверждения от обычных пользователей.
## Матрица допустимости статусов по типам контента
### Для `TEXT_SERVICE`
Разрешены:
- `STATUS_DONE_ONCE`
- `STATUS_CONFIRMED`
Не разрешены:
- `INTERESTED`
- `STARTED`
- `IN_STUDY`
- `COMPLETED`
- `ABANDONED`
- `LEARNED`
### Для `TEXT_EXERCISE`
Разрешены:
- `STATUS_DONE_ONCE`
- `STATUS_LEARNED`
- `STATUS_CONFIRMED`
Логика:
- упражнение можно выполнять много раз;
- упражнение можно выучить;
- `LEARNED` и `DONE_ONCE` не конфликтуют и живут параллельно.
### Для `TEXT_COURSE`
Разрешены:
- `STATUS_INTERESTED`
- `STATUS_STARTED`
- `STATUS_IN_STUDY`
- `STATUS_COMPLETED`
- `STATUS_ABANDONED`
- `STATUS_CONFIRMED`
### Для `TEXT_ENTRYPOINT`
Статусные действия не ставятся.
### Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_RATING`, `TEXT_CHANNEL_META`
Статусные действия не ставятся.
## Накопительные и текущие состояния
Нужно различать:
- накопительные события;
- текущий статус.
### Накопительные события
К ним относится:
- `STATUS_DONE_ONCE`
Они могут встречаться сколько угодно раз.
### Текущий статус
Для некоторых статусов есть “актуальное состояние”.
Например:
- `INTERESTED`
- `STARTED`
- `IN_STUDY`
- `COMPLETED`
- `ABANDONED`
- `LEARNED`
Текущий статус определяется как последнее событие соответствующей оси.
### Для упражнения
У упражнения есть две независимые оси:
- количественная: сколько раз выполнено;
- качественная: выучено или нет.
Это значит:
- `STATUS_DONE_ONCE` накапливается;
- `STATUS_LEARNED` живёт как отдельный качественный статус.
### Для курса
У курса текущий статус вычисляется по последнему из:
- `INTERESTED`
- `STARTED`
- `IN_STUDY`
- `COMPLETED`
- `ABANDONED`
## Виртуальная лента достижений пользователя
Согласована важная идея:
- не нужно создавать отдельный физический канал для истории выполнений пользователя;
- вместо этого строится виртуальная лента из его `STATUS_ACTION`.
### Что это даёт
Можно одновременно получить:
- историю человека;
- статистику по самому материалу.
Например, по упражнению можно увидеть:
- сколько разных людей его выполняли;
- сколько всего выполнений было;
- сколько раз конкретный человек его выполнял;
- кто его выучил.
### Что попадает в виртуальную ленту
Попадают status-события пользователя:
- выполненные упражнения;
- выученные упражнения;
- интерес к курсам;
- начатые курсы;
- обучение в процессе;
- завершённые курсы;
- брошенные курсы;
- прохождения услуг;
- подтверждения к этим событиям.
### Можно ли это обсуждать
Да.
Такие статусные сообщения остаются обычными объектами обсуждения:
- на них можно отвечать;
- их можно комментировать;
- на них можно писать отзывы;
- их можно подтверждать.
То есть:
- лента виртуальная;
- но сами записи реальные и обсуждаемые.
## Как это должно выглядеть в интерфейсе
### В каналах
Для разных типов сообщений UI должен понимать роль сообщения.
Например:
- у `TEXT_EXERCISE` можно показать кнопки:
- `Выполнил`
- `Выучил`
- у `TEXT_SERVICE`:
- `Прошёл`
- у `TEXT_COURSE`:
- `Интересно`
- `Начал`
- `Учусь`
- `Завершил`
- `Бросил`
### Для `entrypoint`
Если канал открывается по основной ссылке и у него есть `entrypoint`, UI:
- показывает сам последний `entrypoint`;
- показывает число новых сообщений после него;
- показывает кнопку перехода в конец канала.
Если пользователь уже подписан на канал:
- открывать лучше место новых непрочитанных сообщений;
- но с кнопкой открытия последнего `entrypoint`.
### Для виртуальной ленты достижений
В профиле пользователя UI может показывать:
- отдельную вкладку / раздел;
- где лента строится из его `STATUS_ACTION`.
## Что именно не нужно делать в первой итерации
### Не нужен отдельный физический канал достижений
Технически не нужен.
### Не нужны отдельные edit-подтипы для каждого вида контента
Не нужны:
- `TEXT_EDIT_EXERCISE`
- `TEXT_EDIT_SERVICE`
- `TEXT_EDIT_COURSE`
Достаточно текущего разделения:
- `TEXT_EDIT_POST`
- `TEXT_EDIT_REPLY`
### Не нужен отдельный верхнеуровневый тип для `entrypoint`
Он остаётся подтипом `type=1`.
### Не нужен `EDIT` для status-событий
`STATUS_ACTION` не редактируются.
Если нужно изменить смысл, пишется новое событие.
### Не нужен `CHANNEL_MEMBERSHIP` в первой итерации
Эта тема отложена в отдельный черновик.
## Сводная таблица
### Верхнеуровневые `type`
| Код | Имя | Статус |
|---|---|---|
| 0 | TECH | старый |
| 1 | TEXT | старый код, новое расширенное смысловое описание |
| 2 | REACTION | старый |
| 3 | CONNECTION | старый |
| 4 | USER_PARAM | старый |
| 5 | STATUS_ACTION | первая итерация |
| 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик |
### Подтипы `type=1`
| subType | Имя | Смысл |
|---|---|---|
| 10 | TEXT_POST | обычный post в линии канала |
| 11 | TEXT_EDIT_POST | edit line-based сообщения |
| 20 | TEXT_REPLY | target-based reply |
| 21 | TEXT_EDIT_REPLY | edit reply |
| 30 | TEXT_RATING | target-based отзыв на конкретный блок |
| 50 | TEXT_REPOST | отложенная будущая заготовка |
| 90 | TEXT_CHANNEL_META | скрытые метаданные канала |
| 100 | TEXT_ENTRYPOINT | входная страница канала |
| 110 | TEXT_EXERCISE | упражнение |
| 120 | TEXT_SERVICE | услуга / процедура |
| 130 | TEXT_COURSE | курс |
### Подтипы `type=5`
| subType | Имя | Смысл |
|---|---|---|
| 10 | STATUS_DONE_ONCE | выполнил / прошёл один раз |
| 20 | STATUS_INTERESTED | заинтересовался |
| 30 | STATUS_STARTED | начал |
| 40 | STATUS_IN_STUDY | учится полноценно |
| 50 | STATUS_COMPLETED | завершил |
| 60 | STATUS_ABANDONED | бросил |
| 70 | STATUS_LEARNED | выучил |
| 80 | STATUS_CONFIRMED | подтверждение status-события |
## Что ещё нужно отдельно утвердить перед реализацией
Перед началом реальной реализации желательно отдельно утвердить:
- точный байтовый формат новых line-based `subType` внутри `type=1`;
- точный байтовый формат `TEXT_RATING` как target-based отзыва;
- точный байтовый формат `STATUS_ACTION`;
- правила target-ссылок для status-событий;
- правила target-ссылок для отзывов на status-события;
- поведение удаления:
- можно ли логически удалять `TEXT_EXERCISE` / `TEXT_SERVICE` / `TEXT_COURSE`;
- можно ли удалять старые `entrypoint` или только оставлять их в истории;
- серверные read-model таблицы и индексы.
## Предварительная оценка готовности к реализации
На текущий момент концепция уже достаточно зрелая, чтобы начинать проектирование реализации первой итерации.
Реализовывать это уже можно, если дополнительно утвердить:
- байтовые форматы;
- правила валидации;
- API чтения новых сущностей;
- UI-матрицу действий по каждому типу контента.
## Краткий итог
Согласованная модель первой итерации сейчас такая:
- почти всё новое содержимое каналов живёт внутри `type=1`;
- `entrypoint` — это специальное текстовое сообщение, а не отдельный верхнеуровневый тип;
- `entrypoint` не редактируется, а версионируется новыми сообщениями;
- канал `0` становится каналом публикаций пользователя и его главной страницей;
- действия пользователя по материалам выносятся в `STATUS_ACTION`;
- `TEXT_RATING` трактуется как target-based отзыв на конкретный блок;
- `CHANNEL_MEMBERSHIP` отложен в отдельный черновик и в первую итерацию не входит.