Compare commits

...
Author SHA256 Message Date
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
69 changed files with 5039 additions and 1149 deletions
@@ -36,6 +36,10 @@ public final class BodyRecordParser {
case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_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_CHANNEL_META & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
@@ -46,12 +50,17 @@ public final class BodyRecordParser {
yield new TextReplyBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
yield new TextRatingBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
@@ -64,17 +64,29 @@ public final class MsgSubType {
*/
public static final short TEXT_EDIT_REPLY = 21;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/**
* REPOST — репост сообщения в линии канала.
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 30;
public static final short TEXT_REPOST = 50;
/**
* CHANNEL_META — скрытый технический снимок профиля канала.
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
*/
public static final short TEXT_CHANNEL_META = 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) ===================== */
@@ -144,4 +156,16 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
}
@@ -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:
* - POST (10)
* - EDIT_POST (11)
* - REPOST (30)
* - CHANNEL_META (70)
* - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
*
* Формат bodyBytes (BigEndian):
*
* POST / CHANNEL_META:
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
@@ -91,7 +95,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 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);
}
@@ -159,12 +167,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 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");
}
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");
}
@@ -211,7 +223,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 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);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
@@ -238,8 +254,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else {
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
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");
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
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)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || 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;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
@@ -318,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
private static boolean requiresNonBlankMessage(int st) {
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -0,0 +1,157 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody — type=1, ver=1.
*
* subType:
* - RATING (30)
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -32,8 +32,13 @@ public final class DatabaseInitializer {
public static final short TEXT_EDIT_POST = 11;
public static final short TEXT_REPLY = 20;
public static final short TEXT_EDIT_REPLY = 21;
public static final short TEXT_REPOST = 30;
public static final short TEXT_CHANNEL_META = 70;
public static final short TEXT_RATING = 30;
public static final short TEXT_REPOST = 50;
public static final short TEXT_CHANNEL_META = 90;
public static final short TEXT_ENTRYPOINT = 100;
public static final short TEXT_EXERCISE = 110;
public static final short TEXT_SERVICE = 120;
public static final short TEXT_COURSE = 130;
public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2;
@@ -30,11 +30,23 @@ public final class MsgSubType {
/** EDIT_REPLY — редактирование исходного ответа. */
public static final short TEXT_EDIT_REPLY = 21;
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
public static final short TEXT_REPOST = 30;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
public static final short TEXT_REPOST = 50;
/** CHANNEL_META — скрытый технический снимок профиля канала. */
public static final short TEXT_CHANNEL_META = 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) ===================== */
@@ -123,6 +135,18 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
// не трогая уже занятые коды.
@@ -13,7 +13,7 @@ import java.util.List;
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
* - login цели (channelLogin)
* - blockchainName цели (channelBchName)
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - last publication: bytes оригинального блока (для timestamp)
* - last publication: bytes актуального блока (edit или orig) — для текста превью
*
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
/**
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
* - count публикаций (TEXT_POST)
* - count публикаций (видимые line-based TEXT-сообщения канала)
* - последнюю публикацию (orig bytes) + её edit (если есть)
*
* Поведение при 0 публикаций:
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub AS (
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
ON s.channel_login = b.login
AND s.channel_bch_name = b.bch_name
WHERE b.msg_type = ?
AND b.msg_sub_type IN (?, ?)
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
GROUP BY b.login, b.bch_name
),
last_pub_block AS (
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
// last_pub
ps.setInt(i++, MSG_TYPE_TEXT);
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
@@ -67,6 +67,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
@@ -75,6 +76,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
@@ -184,6 +186,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
@@ -265,6 +268,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget;
import blockchain.body.CreateChannelBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody;
import org.slf4j.Logger;
@@ -167,6 +168,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_name_already_exists" -> "Такое название канала уже занято";
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST";
case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION";
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
default -> "Ошибка: " + code;
@@ -388,6 +392,33 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
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
int expectedBlockNumber = serverLastNum + 1;
if (block.blockNumber != expectedBlockNumber) {
@@ -605,6 +636,63 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
String slug;
}
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
if (!(block.body instanceof TextLineBody editBody)) return null;
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
byte[] targetHash = editBody.toBlockHashBytes();
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
}
if (target.getMsgType() != 1) return null;
if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) {
return "entrypoint_edit_forbidden";
}
return null;
}
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
String targetBch = statusBody.toBchName();
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
byte[] targetHash = statusBody.toBlockHashBytes();
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return "bad_block_body";
}
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
}
int statusSubType = block.subType & 0xFFFF;
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
if (target.getMsgType() != 5) {
return "status_confirmed_target_must_be_status_action";
}
return null;
}
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
int targetSubType = target.getMsgSubType();
if (statusSubType == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_LEARNED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
if (statusSubType == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
if (statusSubType == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| statusSubType == (MsgSubType.STATUS_COMPLETED & 0xFFFF)) {
return targetSubType == (MsgSubType.TEXT_COURSE & 0xFFFF) ? null : "status_action_target_not_allowed";
}
return "status_action_target_not_allowed";
}
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
@@ -22,6 +22,7 @@ final class ChannelsReadSupport {
static final int MSG_TYPE_TEXT = 1;
static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0;
static final int MSG_TYPE_STATUS_ACTION = 5;
static final String STORIES_CHANNEL_NAME = "stories";
static final String COMMAND_ADD = "add";
static final String COMMAND_REMOVE = "remove";
@@ -126,13 +127,17 @@ final class ChannelsReadSupport {
}
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?";
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
@@ -143,7 +148,7 @@ final class ChannelsReadSupport {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
ORDER BY block_number DESC
LIMIT 1
""";
@@ -152,7 +157,11 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
@@ -204,6 +213,10 @@ final class ChannelsReadSupport {
ti.text = tlb.message;
} else if (e.body instanceof TextReplyBody trb) {
ti.text = trb.message;
} else if (e.body instanceof blockchain.body.TextRatingBody trb) {
ti.text = trb.message;
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
ti.text = sab.message;
} else if (e.body instanceof TextBody tb) {
ti.text = tb.message;
}
@@ -218,7 +231,7 @@ final class ChannelsReadSupport {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
ORDER BY block_number
""" + order + " LIMIT ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -226,8 +239,12 @@ final class ChannelsReadSupport {
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(6, limit);
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
ps.setInt(10, limit);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
@@ -292,15 +309,42 @@ final class ChannelsReadSupport {
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
int likesCount = 0;
int repliesCount = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new int[] {0, 0};
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
if (rs.next()) {
likesCount = rs.getInt("likes_count");
repliesCount = rs.getInt("replies_count");
}
}
}
String ratingsSql = """
SELECT COUNT(*)
FROM blocks
WHERE msg_type = ?
AND msg_sub_type = ?
AND to_bch_name = ?
AND to_block_number = ?
AND to_block_hash = ?
""";
int ratingsCount = 0;
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
ps.setInt(1, MSG_TYPE_TEXT);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, bch);
ps.setInt(4, blockNumber);
ps.setBytes(5, blockHash);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
ratingsCount = rs.getInt(1);
}
}
}
return new int[] {likesCount, repliesCount, ratingsCount};
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
@@ -589,6 +633,22 @@ final class ChannelsReadSupport {
return sb.toString();
}
static boolean isChannelFeedSubType(int subType) {
return subType == MsgSubType.TEXT_POST
|| subType == MsgSubType.TEXT_REPOST
|| subType == MsgSubType.TEXT_ENTRYPOINT
|| subType == MsgSubType.TEXT_EXERCISE
|| subType == MsgSubType.TEXT_SERVICE
|| subType == MsgSubType.TEXT_COURSE;
}
static boolean supportsEditPostVersions(int subType) {
return subType == MsgSubType.TEXT_POST
|| subType == MsgSubType.TEXT_EXERCISE
|| subType == MsgSubType.TEXT_SERVICE
|| subType == MsgSubType.TEXT_COURSE;
}
static final class PostBlock {
String login;
String bchName;
@@ -143,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
v1.setCreatedAtMs(postText.createdAtMs);
versionsOut.add(v1);
if (post.msgSubType == MsgSubType.TEXT_POST) {
if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) {
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
@@ -167,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
item.setRatingsCount(stats[2]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item);
@@ -17,6 +17,7 @@ import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
@@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
if (depthDown <= 0) return List.of();
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<PostRow> replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
for (PostRow row : replies) {
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
return out;
}
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE msg_type=1 AND msg_sub_type=?
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
LIMIT ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MsgSubType.TEXT_REPLY);
ps.setString(2, toBchName);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
ps.setInt(5, limit);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, toBchName);
ps.setInt(4, toBlockNumber);
ps.setBytes(5, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));
out.sort(Comparator
.comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs)
.thenComparing(row -> String.valueOf(row.bchName))
.thenComparingInt(row -> row.blockNumber));
if (out.size() > limit) {
return new ArrayList<>(out.subList(0, limit));
}
return out;
}
}
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
first.setCreatedAtMs(base.createdAtMs);
versions.add(first);
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) {
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
if (row.msgSubType == MsgSubType.TEXT_REPLY
|| row.msgSubType == MsgSubType.TEXT_RATING
|| ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
short editType = (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_RATING)
? MsgSubType.TEXT_EDIT_REPLY
: MsgSubType.TEXT_EDIT_POST;
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
@@ -233,6 +243,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
node.setRatingsCount(stats[2]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
@@ -0,0 +1,238 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.body.StatusActionBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetPersonalDiary_Handler.class);
private static final String DIARY_CHANNEL_NAME = "diary";
private static final String DIARY_DISPLAY_NAME = "Личный дневник";
private static final String DIARY_DESCRIPTION = "История ваших действий по упражнениям, услугам и курсам";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetPersonalDiary_Request req = (Net_GetPersonalDiary_Request) baseRequest;
String requestedLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim();
if (requestedLogin.isBlank() && (ctx == null || ctx.getLogin() == null || ctx.getLogin().isBlank())) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
}
int limit = req.getLimit() == null ? 200 : req.getLimit();
if (limit <= 0 || limit > 1000) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
}
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
try (Connection c = DbController.getInstance().getConnection()) {
String viewerLogin = ctx != null ? String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim() : "";
String canonicalLogin = !viewerLogin.isBlank()
? viewerLogin
: ChannelsReadSupport.canonicalLogin(c, requestedLogin);
if (canonicalLogin == null || canonicalLogin.isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
}
if (!viewerLogin.isBlank() && !viewerLogin.equalsIgnoreCase(canonicalLogin)) {
return NetExceptionResponseFactory.error(req, 403, "forbidden", "Личный дневник доступен только владельцу");
}
String ownerBch = loadPrimaryBlockchainName(c, canonicalLogin);
if (ownerBch == null || ownerBch.isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "blockchain_not_found", "Не найден blockchain пользователя");
}
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
channel.setOwnerLogin(canonicalLogin);
channel.setOwnerBlockchainName(ownerBch);
channel.setChannelName(DIARY_CHANNEL_NAME);
channel.setDisplayName(DIARY_DISPLAY_NAME);
channel.setChannelDescription(DIARY_DESCRIPTION);
channel.setChannelTypeCode(900);
channel.setChannelTypeVersion(1);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(0);
rootRef.setBlockHash(ChannelsReadSupport.toHex(new byte[32]));
channel.setChannelRoot(rootRef);
resp.setChannel(channel);
resp.setMetaEvents(new ArrayList<>());
List<Net_GetChannelMessages_Response.MessageItem> items = loadDiaryItems(c, canonicalLogin, limit, asc);
resp.setMessages(items);
return resp;
} catch (Exception e) {
log.error("GetPersonalDiary failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private String loadPrimaryBlockchainName(Connection c, String canonicalLogin) throws Exception {
try (PreparedStatement ps = c.prepareStatement("""
SELECT blockchain_name
FROM blockchain_state
WHERE login = ?
ORDER BY blockchain_name
LIMIT 1
""")) {
ps.setString(1, canonicalLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("blockchain_name") : null;
}
}
}
private List<Net_GetChannelMessages_Response.MessageItem> loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception {
String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
FROM blocks
WHERE login = ? AND msg_type = ?
ORDER BY block_number
""" + order + " LIMIT ?")) {
ps.setString(1, canonicalLogin);
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_STATUS_ACTION);
ps.setInt(3, limit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
byte[] blockBytes = rs.getBytes("block_bytes");
BchBlockEntry entry = new BchBlockEntry(blockBytes);
if (!(entry.body instanceof StatusActionBody statusBody)) continue;
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
ref.setBlockNumber(rs.getInt("block_number"));
ref.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
item.setMessageRef(ref);
item.setMsgSubType(rs.getInt("msg_sub_type"));
item.setAuthorLogin(rs.getString("login"));
item.setAuthorBlockchainName(rs.getString("bch_name"));
item.setCreatedAtMs(entry.timestamp * 1000L);
item.setLikesCount(0);
item.setLikedByMe(false);
item.setRepliesCount(0);
item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName());
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c,
rs.getString("bch_name"),
rs.getInt("block_number"),
rs.getBytes("block_hash"),
statusBody.message == null ? "" : statusBody.message,
entry.timestamp * 1000L
);
item.setVersions(versions);
item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
out.add(item);
}
}
}
return out;
}
private List<Net_GetChannelMessages_Response.VersionItem> loadVersionsForDiaryItem(Connection c,
String ownerBch,
int originalBlockNumber,
byte[] originalBlockHash,
String originalText,
long originalCreatedAtMs) throws Exception {
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
first.setVersionIndex(1);
first.setBlockNumber(originalBlockNumber);
first.setBlockHash(ChannelsReadSupport.toHex(originalBlockHash));
first.setText(originalText == null ? "" : originalText);
first.setCreatedAtMs(originalCreatedAtMs);
versions.add(first);
try (PreparedStatement ps = c.prepareStatement("""
SELECT block_number, block_hash, block_bytes
FROM blocks
WHERE bch_name = ?
AND msg_type = ?
AND msg_sub_type = ?
AND to_block_number = ?
AND to_block_hash = ?
AND (to_bch_name = ? OR to_bch_name IS NULL OR to_bch_name = '')
ORDER BY block_number ASC
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_TEXT);
ps.setInt(3, shine.db.MsgSubType.TEXT_EDIT_REPLY);
ps.setInt(4, originalBlockNumber);
ps.setBytes(5, originalBlockHash);
ps.setString(6, ownerBch);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
Net_GetChannelMessages_Response.VersionItem item = new Net_GetChannelMessages_Response.VersionItem();
item.setVersionIndex(versions.size() + 1);
item.setBlockNumber(rs.getInt("block_number"));
item.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
item.setText(editText.text);
item.setCreatedAtMs(editText.createdAtMs);
versions.add(item);
}
}
}
return versions;
}
private void fillTargetDetails(Connection c,
Net_GetChannelMessages_Response.MessageItem item,
String targetBch,
Integer targetBlockNumber,
byte[] targetHash) throws Exception {
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return;
}
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
""")) {
ps.setString(1, targetBch);
ps.setInt(2, targetBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return;
byte[] actualHash = rs.getBytes("block_hash");
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
item.setTargetAuthorLogin(rs.getString("login"));
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
item.setTargetText(textInfo.text);
item.setTargetCreatedAtMs(textInfo.createdAtMs);
}
}
}
}
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
AND block_number = ?
AND block_hash = ?
AND msg_type = ?
AND msg_sub_type = ?
AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
%s
LIMIT 1
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
existsPs.setInt(5, MsgSubType.TEXT_POST);
existsPs.setInt(6, MsgSubType.TEXT_REPOST);
existsPs.setInt(7, MsgSubType.TEXT_ENTRYPOINT);
existsPs.setInt(8, MsgSubType.TEXT_EXERCISE);
existsPs.setInt(9, MsgSubType.TEXT_SERVICE);
existsPs.setInt(10, MsgSubType.TEXT_COURSE);
if (strictChannelMatch) {
existsPs.setInt(6, expectedRoot);
existsPs.setInt(11, expectedRoot);
}
boolean exists;
@@ -127,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String targetBlockchainName;
private Integer targetBlockNumber;
private String targetBlockHash;
private Integer targetMsgSubType;
private String targetText;
private String targetAuthorLogin;
private String targetAuthorBlockchainName;
private Long targetCreatedAtMs;
private long createdAtMs;
private String text;
private int likesCount;
private boolean likedByMe;
private int repliesCount;
private int ratingsCount;
private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>();
@@ -158,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
public Integer getTargetMsgSubType() { return targetMsgSubType; }
public void setTargetMsgSubType(Integer targetMsgSubType) { this.targetMsgSubType = targetMsgSubType; }
public String getTargetText() { return targetText; }
public void setTargetText(String targetText) { this.targetText = targetText; }
public String getTargetAuthorLogin() { return targetAuthorLogin; }
public void setTargetAuthorLogin(String targetAuthorLogin) { this.targetAuthorLogin = targetAuthorLogin; }
public String getTargetAuthorBlockchainName() { return targetAuthorBlockchainName; }
public void setTargetAuthorBlockchainName(String targetAuthorBlockchainName) { this.targetAuthorBlockchainName = targetAuthorBlockchainName; }
public Long getTargetCreatedAtMs() { return targetCreatedAtMs; }
public void setTargetCreatedAtMs(Long targetCreatedAtMs) { this.targetCreatedAtMs = targetCreatedAtMs; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
@@ -173,6 +194,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
public int getRatingsCount() { return ratingsCount; }
public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; }
public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
@@ -0,0 +1,18 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetPersonalDiary_Request extends Net_Request {
private String login;
private Integer limit;
private String sort;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public String getSort() { return sort; }
public void setSort(String sort) { this.sort = sort; }
}
@@ -26,6 +26,7 @@ import java.util.Set;
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int TYPE_INVITE = 100;
private static final int TYPE_CONNECT_START = 170;
private static final long PUSH_CALL_TTL_MS = 10_000L;
@Override
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
int type = req.getType() == null ? TYPE_INVITE : req.getType();
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
String data = req.getData() == null ? "" : req.getData().trim();
boolean isInvite = type == TYPE_INVITE;
boolean isConnectStart = type == TYPE_CONNECT_START;
if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны");
}
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
payload.put("fromSessionId", ctx.getSessionId());
payload.put("toLogin", to);
payload.put("callId", callId);
payload.put("type", TYPE_INVITE);
payload.put("type", type);
payload.put("timeMs", timeMs);
if (!data.isBlank()) {
payload.put("data", data);
}
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
if (sent) wsDelivered++;
}
if (isConnectStart) {
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setCallId(callId);
resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(0);
resp.setDeliveredWebPushSessions(0);
return resp;
}
for (ActiveSessionEntry session : allTargetSessions) {
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
@@ -12,10 +12,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.CurrentUserEntry;
@@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
private static final String TARGET_MODE_SINGLE = "single_session";
private static final String TARGET_MODE_ALL = "all_sessions";
private static final long ALLOWED_SKEW_MS = 30_000L;
private static final long PUSH_CALL_TTL_MS = 10_000L;
private static final String SIGNAL_CALL_INVITE = "call_invite";
private static final String SIGNAL_CALL_ACCEPT = "call_accept";
private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy";
private static final String SIGNAL_CALL_TIMEOUT = "call_timeout";
private static final String SIGNAL_CALL_HANGUP = "call_hangup";
private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
@@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
}
if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key");
}
if (!clientSignatureB64.isBlank()) {
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
@@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
}
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
if (targets.isEmpty()) {
boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType);
boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType);
boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode)
&& (SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|| SIGNAL_CALL_HANGUP.equals(signalType));
if (targets.isEmpty() && !isCallInviteAllSessions) {
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
return NetExceptionResponseFactory.error(req, 404, code, msg);
@@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
}
}
if (deliveredSessionIds.isEmpty()) {
int webPushDelivered = 0;
if (isCallInviteAllSessions) {
webPushDelivered = sendIncomingCallPushToOfflineSessions(
toLogin,
fromLogin,
fromSessionId,
signalRequestId,
deliveredSessionIds
);
}
if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) {
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
}
if (isCallAcceptSingleSession) {
notifyStopOnOtherSessions(
fromLogin,
fromSessionId,
fromLogin,
fromSessionId,
signalRequestId,
"accepted_on_other_device"
);
}
if (isCallTerminalSingleSession) {
String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0);
String reason = "terminal_call_signal_" + signalType;
notifyStopOnOtherSessions(
fromLogin,
fromSessionId,
fromLogin,
fromSessionId,
signalRequestId,
reason
);
notifyStopOnOtherSessions(
toLogin,
deliveredTargetSessionId,
fromLogin,
fromSessionId,
signalRequestId,
reason
);
}
Net_SendSignal_Response resp = new Net_SendSignal_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setDeliveredCount(deliveredSessionIds.size());
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
resp.setDeliveredSessionIds(deliveredSessionIds);
resp.setDeliveredWsSessions(deliveredSessionIds.size());
resp.setDeliveredFcmSessions(webPushDelivered);
resp.setDeliveredWebPushSessions(webPushDelivered);
return resp;
}
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
+ dataSha256B64;
}
private static boolean isCallSignalType(String signalType) {
return SIGNAL_CALL_INVITE.equals(signalType)
|| "call_ringing".equals(signalType)
|| SIGNAL_CALL_ACCEPT.equals(signalType)
|| SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|| SIGNAL_CALL_HANGUP.equals(signalType)
|| SIGNAL_CALL_CONNECT_START.equals(signalType)
|| "call_offer".equals(signalType)
|| "call_answer".equals(signalType)
|| "call_ice".equals(signalType);
}
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
return targets;
}
private int sendIncomingCallPushToOfflineSessions(
String toLogin,
String fromLogin,
String fromSessionId,
String callId,
List<String> deliveredOnlineSessionIds
) throws Exception {
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin);
long sentAtMs = System.currentTimeMillis();
long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS;
int delivered = 0;
for (ActiveSessionEntry session : persistedSessions) {
String sessionId = safe(session.getSessionId());
if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) {
continue;
}
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
continue;
}
String payload = "{\"kind\":\"incoming_call\""
+ ",\"title\":\"SHiNE: входящий звонок\""
+ ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(toLogin) + "\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ ",\"expiresAtMs\":" + expiresAtMs
+ "}";
boolean pushed = WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
payload
);
if (pushed) {
delivered++;
}
}
return delivered;
}
private void notifyStopOnOtherSessions(
String targetLogin,
String excludeSessionId,
String fromLogin,
String fromSessionId,
String callId,
String reason
) throws Exception {
if (isBlank(targetLogin) || isBlank(callId)) {
return;
}
Set<String> onlineSessionIds = new java.util.HashSet<>();
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
for (ConnectionContext siblingCtx : sameUserSessions) {
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
onlineSessionIds.add(safe(siblingCtx.getSessionId()));
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
ObjectNode siblingPayload = MAPPER.createObjectNode();
siblingPayload.put("eventId", siblingEventId);
siblingPayload.put("fromLogin", fromLogin);
siblingPayload.put("fromSessionId", fromSessionId);
siblingPayload.put("toLogin", targetLogin);
siblingPayload.put("targetMode", TARGET_MODE_SINGLE);
siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId()));
siblingPayload.put("signalType", SIGNAL_CALL_HANGUP);
siblingPayload.put("signalRequestId", callId);
siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}");
siblingPayload.put("timeMs", System.currentTimeMillis());
WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload);
}
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
long sentAtMs = System.currentTimeMillis();
for (ActiveSessionEntry session : persistedSessions) {
String sessionId = safe(session.getSessionId());
if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue;
if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue;
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
continue;
}
String pushPayload = "{\"kind\":\"stop_call\""
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
+ ",\"sentAtMs\":" + sentAtMs
+ "}";
WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
session.getPushP256dhKey(),
session.getPushAuthKey(),
pushPayload
);
}
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String jsonEscape(String s) {
if (s == null) return "";
StringBuilder out = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') out.append("\\\\");
else if (c == '"') out.append("\\\"");
else if (c == '\n') out.append("\\n");
else if (c == '\r') out.append("\\r");
else if (c == '\t') out.append("\\t");
else out.append(c);
}
return out.toString();
}
}
@@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
private String toLogin;
private String callId;
private Integer type;
private String data;
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
@@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
public Integer getType() { return type; }
public void setType(Integer type) { this.type = type; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}
@@ -8,6 +8,9 @@ import java.util.List;
public class Net_SendSignal_Response extends Net_Response {
private int deliveredCount;
private List<String> deliveredSessionIds = new ArrayList<>();
private int deliveredWsSessions;
private int deliveredFcmSessions;
private int deliveredWebPushSessions;
public int getDeliveredCount() {
return deliveredCount;
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
this.deliveredSessionIds = deliveredSessionIds;
}
public int getDeliveredWsSessions() {
return deliveredWsSessions;
}
public void setDeliveredWsSessions(int deliveredWsSessions) {
this.deliveredWsSessions = deliveredWsSessions;
}
public int getDeliveredFcmSessions() {
return deliveredFcmSessions;
}
public void setDeliveredFcmSessions(int deliveredFcmSessions) {
this.deliveredFcmSessions = deliveredFcmSessions;
}
public int getDeliveredWebPushSessions() {
return deliveredWebPushSessions;
}
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) {
this.deliveredWebPushSessions = deliveredWebPushSessions;
}
}
+1
View File
@@ -49,6 +49,7 @@
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload.
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
### dao_запуск
@@ -0,0 +1,89 @@
# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key`
## Зачем
Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`.
При этом в коде всё ещё живут старые call-like транспорты:
- `CallInviteBroadcast`
- `CallSignalToSession`
Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`.
Отдельный хвост остался в ESP32 homeserver/wallet-сценариях:
- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`;
- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`;
- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку.
## Что уже есть в коде
- универсальный серверный transport `SendSignal` уже существует и поддерживает:
- `single_session`;
- `all_sessions`.
- web UI уже использует `SendSignal` в части межсессионных сценариев.
- звонки пока ещё используют старые call-specific методы.
- ESP32 homeserver main использует:
- `CallSignalToSession` для wallet RPC response;
- `SendSignal` для части технических ответов.
- в ESP32 есть код генерации:
- `sessionSignatureB64`;
- `clientSignatureB64`;
но для некоторых send-signal вызовов `client key` сейчас не обязателен.
## Что сделать
1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`.
2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`.
3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE.
4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`.
5. После этого зачистить legacy call-like транспорт там, где он больше не нужен.
6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на:
- `IncomingCallSignal`;
- `IncomingCallInvite`;
там, где должен работать общий `IncomingSignal`.
## Отдельно про шифрование
Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload.
Предварительный вывод по текущей архитектуре:
- для сервера это почти не должно требовать специальной логики;
- сервер в `SendSignal` в основном:
- валидирует подписи;
- маршрутизирует событие в нужные сессии;
- пересылает `data` дальше.
Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока:
- формат `data` остаётся строковым/JSON-совместимым;
- сервер продолжает считать `SHA-256` от фактической передаваемой строки;
- подписи строятся по уже зашифрованному payload.
То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации.
## Что важно учесть при миграции
- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком;
- сначала перевести ESP32 и wallet-extension на единый `SendSignal`;
- только потом убирать старые call-specific методы;
- не оставлять “особый ESP32-режим” без `client key`;
- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки.
## Откуда продолжать
- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
- от текущего ESP32 homeserver-скетча:
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
- от web UI auth/call transport логики:
- `shine-UI/js/services/auth-service.js`
- `shine-UI/js/services/call-service.js`
## Какие документы потом обновить
- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
- `TODO/README.md`;
- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract;
- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.5.1
server.version=1.4.5
client.version=1.5.27
server.version=1.4.10
+3 -3
View File
@@ -1,3 +1,3 @@
backup.schema.version=1
backup.full.version=2
last.full.backup.date=2026-07-10
backup.schema.version=2
backup.full.version=3
last.full.backup.date=2026-08-08
@@ -1,17 +1,15 @@
cld9-012186
p702072.kvmvps
---
Linux cld9-012186 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
Linux p702072.kvmvps 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
---
Filesystem Size Used Avail Use% Mounted on
tmpfs 392M 1.2M 391M 1% /run
/dev/vda2 32G 14G 17G 46% /
tmpfs 2.0G 0 2.0G 0% /dev/shm
tmpfs 197M 1.2M 196M 1% /run
/dev/sda1 40G 6.7G 31G 18% /
tmpfs 982M 0 982M 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 392M 12K 392M 1% /run/user/1002
tmpfs 197M 0 197M 0% /run/user/1000
---
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00
sr1
vda
├─vda1
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
sda
└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
sr0
@@ -1,13 +1,48 @@
UNIT FILE STATE PRESET
agent-memory.service enabled enabled
caddy.service enabled enabled
containerd.service enabled enabled
coturn.service enabled enabled
docker.service enabled enabled
elaira-agent.service enabled enabled
hermes-dashboard.service enabled enabled
hermes-gateway.service enabled enabled
shine-server.service enabled enabled
ubuntu-fan.service enabled enabled
UNIT FILE STATE VENDOR PRESET
apparmor.service enabled enabled
blk-availability.service enabled enabled
caddy.service enabled enabled
console-setup.service enabled enabled
containerd.service enabled enabled
coturn.service enabled enabled
cron.service enabled enabled
dmesg.service enabled enabled
docker.service enabled enabled
e2scrub_reap.service enabled enabled
finalrd.service enabled enabled
getty@.service enabled enabled
gpu-manager.service enabled enabled
grub-common.service enabled enabled
grub-initrd-fallback.service enabled enabled
guestfs-firstboot.service enabled enabled
irqbalance.service enabled enabled
keyboard-setup.service enabled enabled
lvm2-monitor.service enabled enabled
lxd-agent.service enabled enabled
ModemManager.service enabled enabled
multipathd.service enabled enabled
networkd-dispatcher.service enabled enabled
open-iscsi.service enabled enabled
open-vm-tools.service enabled enabled
pollinate.service enabled enabled
rsyslog.service enabled enabled
secureboot-db.service enabled enabled
setvtrgb.service enabled enabled
shine-server.service enabled enabled
snap.lxd.activate.service enabled enabled
ssh.service enabled enabled
systemd-networkd-wait-online.service enabled disabled
systemd-networkd.service enabled enabled
systemd-pstore.service enabled enabled
systemd-resolved.service enabled enabled
systemd-timesyncd.service enabled enabled
thermald.service enabled enabled
ua-reboot-cmds.service enabled enabled
ubuntu-advantage.service enabled enabled
ubuntu-fan.service enabled enabled
udisks2.service enabled enabled
ufw.service enabled enabled
unattended-upgrades.service enabled enabled
vgauth.service enabled enabled
10 unit files listed.
45 unit files listed.
@@ -1,20 +1,20 @@
UNIT LOAD ACTIVE SUB DESCRIPTION
agent-memory.service loaded active running agent-memory service
caddy.service loaded active running Caddy
containerd.service loaded active running containerd container runtime
coturn.service loaded active running coTURN STUN/TURN Server
cron.service loaded active running Regular background program processing daemon
dbus.service loaded active running D-Bus System Message Bus
docker.service loaded active running Docker Application Container Engine
elaira-agent.service loaded active running Elaira self-hosted agent
getty@tty1.service loaded active running Getty on tty1
hermes-gateway.service loaded active running Hermes Agent Gateway - Messaging Platform Integration
irqbalance.service loaded active running irqbalance daemon
ModemManager.service loaded active running Modem Manager
multipathd.service loaded active running Device-Mapper Multipath Device Controller
networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd
packagekit.service loaded active running PackageKit Daemon
polkit.service loaded active running Authorization Manager
qemu-guest-agent.service loaded active running QEMU Guest Agent
rsyslog.service loaded active running System Logging Service
shine-server.service loaded active running SHiNE Server
shine-server.service loaded active running SHiNE Server (shineup.me)
ssh.service loaded active running OpenBSD Secure Shell server
systemd-hostnamed.service loaded active running Hostname Service
systemd-journald.service loaded active running Journal Service
@@ -26,10 +26,9 @@
udisks2.service loaded active running Disk Manager
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
upower.service loaded active running Daemon for power management
user@1002.service loaded active running User Manager for UID 1002
Legend: LOAD → Reflects whether the unit definition was properly loaded.
ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
SUB → The low-level unit activation state, values depend on unit type.
user@1000.service loaded active running User Manager for UID 1000
LOAD = Reflects whether the unit definition was properly loaded.
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
SUB = The low-level unit activation state, values depend on unit type.
28 loaded units listed.
@@ -1,32 +1,12 @@
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=15))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37))
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34))
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58))
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60))
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23))
LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6))
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14))
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=17))
LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=8410,fd=7))
LISTEN 0 4096 127.0.0.1:45999 0.0.0.0:* users:(("containerd",pid=2121968,fd=15))
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=8433,fd=7))
LISTEN 0 2048 127.0.0.1:9119 0.0.0.0:* users:(("hermes",pid=1913356,fd=7))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=323588,fd=3),("systemd",pid=1,fd=130))
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=40))
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=17))
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=56))
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=19))
LISTEN 0 100 *:8018 *:* users:(("java",pid=9688,fd=13))
LISTEN 0 50 *:7070 *:* users:(("java",pid=1732508,fd=16))
LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=8417,fd=7))
LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=323588,fd=4),("systemd",pid=1,fd=132))
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=8096,fd=16))
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=8096,fd=15))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=25))
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=62))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=27))
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=64))
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3))
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34))
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33))
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4))
LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16))
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7))
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9))
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4))
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7))
LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19))
@@ -1,2 +1,2 @@
NAMES IMAGE STATUS PORTS
gitea gitea/gitea:1.22.6 Up 5 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp
NAMES IMAGE STATUS PORTS
shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp
@@ -1,8 +1 @@
4.0K /home/player/hosts.codex.test
8.0K /home/player/Work_AGENTS
804K /home/player/sites
33M /home/player/elaira-agent
36M /home/player/agent-memory
46M /home/player/gitea
183M /home/player/SHiNE
1.7G /home/player/hermes
85M /home/player/SHiNE
@@ -2,11 +2,10 @@
4.0K /var/local
4.0K /var/mail
4.0K /var/opt
4.0K /var/snap
16K /var/spool
88K /var/tmp
3.2M /var/backups
143M /var/cache
1.2G /var/lib
1.5G /var/log
72K /var/tmp
1.9M /var/backups
319M /var/cache
1.1G /var/log
1.5G /var/lib
2.8G /var
@@ -1 +1 @@
2026-07-10T08:27:36Z
2026-08-08T20:05:20Z
@@ -1,8 +1,5 @@
openmindsoft.io, www.openmindsoft.io {
encode zstd gzip
root * /home/player/sites/OpenMindSoft.io
try_files {path} /index.html
file_server
{
auto_https disable_redirects
}
shineup.me {
@@ -46,33 +43,3 @@ shineup.me {
}
}
}
git.shineup.me {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
test-solana-tickets.shineup.me, test-solana-tickets.shiningpeople.ru {
encode zstd gzip
root * /home/player/sites/test-solana-tickets.shineup.me
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
hermes.shineup.me {
encode zstd gzip
basicauth {
player $2a$14$a41XVsBhgxgKN2MVS5Vt3Otu4C6mmv2FRo1gYDjvPDEYwYkPGnj1e
}
reverse_proxy 127.0.0.1:9119 {
header_up Host 127.0.0.1:9119
header_up Origin http://127.0.0.1:9119
}
}
@@ -0,0 +1 @@
missing: /etc/systemd/system/agent-memory.service
@@ -1,5 +1,5 @@
[Unit]
Description=SHiNE Server
Description=SHiNE Server (shineup.me)
After=network.target
[Service]
@@ -7,7 +7,7 @@ Type=simple
User=player
Group=player
WorkingDirectory=/home/player/SHiNE/shine-server
ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
ExecStart=/usr/bin/java -Dserver.port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
Restart=always
RestartSec=3
@@ -1,709 +1,17 @@
# Coturn TURN SERVER configuration file
#
# Boolean values note: where boolean value is supposed to be used,
# you can use '0', 'off', 'no', 'false', 'f' as 'false,
# and you can use '1', 'on', 'yes', 'true', 't' as 'true'
# If the value is missed, then it means 'true'.
#
# Listener interface device (optional, Linux only).
# NOT RECOMMENDED.
#
#listening-device=eth0
# TURN listener port for UDP and TCP (Default: 3478).
# Note: actually, TLS & DTLS sessions can connect to the
# "plain" TCP & UDP port(s), too - if allowed by configuration.
#
#listening-port=3478
# TURN listener port for TLS (Default: 5349).
# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS
# port(s), too - if allowed by configuration. The TURN server
# "automatically" recognizes the type of traffic. Actually, two listening
# endpoints (the "plain" one and the "tls" one) are equivalent in terms of
# functionality; but we keep both endpoints to satisfy the RFC 5766 specs.
# For secure TCP connections, we currently support SSL version 3 and
# TLS version 1.0, 1.1 and 1.2.
# For secure UDP connections, we support DTLS version 1.
#
#tls-listening-port=5349
# Alternative listening port for UDP and TCP listeners;
# default (or zero) value means "listening port plus one".
# This is needed for RFC 5780 support
# (STUN extension specs, NAT behavior discovery). The TURN Server
# supports RFC 5780 only if it is started with more than one
# listening IP address of the same family (IPv4 or IPv6).
# RFC 5780 is supported only by UDP protocol, other protocols
# are listening to that endpoint only for "symmetry".
#
#alt-listening-port=0
# Alternative listening port for TLS and DTLS protocols.
# Default (or zero) value means "TLS listening port plus one".
#
#alt-tls-listening-port=0
# Listener IP address of relay server. Multiple listeners can be specified.
# If no IP(s) specified in the config file or in the command line options,
# then all IPv4 and IPv6 system IPs will be used for listening.
#
#listening-ip=172.17.19.101
#listening-ip=10.207.21.238
#listening-ip=2607:f0d0:1002:51::4
# Auxiliary STUN/TURN server listening endpoint.
# Aux servers have almost full TURN and STUN functionality.
# The (minor) limitations are:
#
# 1) Auxiliary servers do not have alternative ports and
# they do not support STUN RFC 5780 functionality (CHANGE REQUEST).
#
# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply.
#
# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6.
#
# There may be multiple aux-server options, each will be used for listening
# to client requests.
#
#aux-server=172.17.19.110:33478
#aux-server=[2607:f0d0:1002:51::4]:33478
# (recommended for older Linuxes only)
# Automatically balance UDP traffic over auxiliary servers (if configured).
# The load balancing is using the ALTERNATE-SERVER mechanism.
# The TURN client must support 300 ALTERNATE-SERVER response for this
# functionality.
#
#udp-self-balance
# Relay interface device for relay sockets (optional, Linux only).
# NOT RECOMMENDED.
#
#relay-device=eth1
# Relay address (the local IP address that will be used to relay the
# packets to the peer).
# Multiple relay addresses may be used.
# The same IP(s) can be used as both listening IP(s) and relay IP(s).
#
# If no relay IP(s) specified, then the turnserver will apply the default
# policy: it will decide itself which relay addresses to be used, and it
# will always be using the client socket IP address as the relay IP address
# of the TURN session (if the requested relay address family is the same
# as the family of the client socket).
#
#relay-ip=172.17.19.105
#relay-ip=2607:f0d0:1002:51::5
# For Amazon EC2 users:
#
# TURN Server public/private address mapping, if the server is behind NAT.
# In that situation, if a -X is used in form "-X <ip>" then that ip will be reported
# as relay IP address of all allocations. This scenario works only in a simple case
# when one single relay address is be used, and no RFC5780 functionality is required.
# That single relay address must be mapped by NAT to the 'external' IP.
# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field.
# For that 'external' IP, NAT must forward ports directly (relayed port 12345
# must be always mapped to the same 'external' port 12345).
#
# In more complex case when more than one IP address is involved,
# that option must be used several times, each entry must
# have form "-X <public-ip/private-ip>", to map all involved addresses.
# RFC5780 NAT discovery STUN functionality will work correctly,
# if the addresses are mapped properly, even when the TURN server itself
# is behind A NAT.
#
# By default, this value is empty, and no address mapping is used.
#
#external-ip=60.70.80.91
#
#OR:
#
#external-ip=60.70.80.91/172.17.19.101
#external-ip=60.70.80.92/172.17.19.102
# Number of the relay threads to handle the established connections
# (in addition to authentication thread and the listener thread).
# If explicitly set to 0 then application runs relay process in a
# single thread, in the same thread with the listener process
# (the authentication thread will still be a separate thread).
#
# If this parameter is not set, then the default OS-dependent
# thread pattern algorithm will be employed. Usually the default
# algorithm is the most optimal, so you have to change this option
# only if you want to make some fine tweaks.
#
# In the older systems (Linux kernel before 3.9),
# the number of UDP threads is always one thread per network listening
# endpoint - including the auxiliary endpoints - unless 0 (zero) or
# 1 (one) value is set.
#
#relay-threads=0
# Lower and upper bounds of the UDP relay endpoints:
# (default values are 49152 and 65535)
#
#min-port=49152
#max-port=65535
# Uncomment to run TURN server in 'normal' 'moderate' verbose mode.
# By default the verbose mode is off.
#verbose
# Uncomment to run TURN server in 'extra' verbose mode.
# This mode is very annoying and produces lots of output.
# Not recommended under any normal circumstances.
#
#Verbose
# Uncomment to use fingerprints in the TURN messages.
# By default the fingerprints are off.
#
#fingerprint
# Uncomment to use long-term credential mechanism.
# By default no credentials mechanism is used (any user allowed).
#
#lt-cred-mech
# This option is opposite to lt-cred-mech.
# (TURN Server with no-auth option allows anonymous access).
# If neither option is defined, and no users are defined,
# then no-auth is default. If at least one user is defined,
# in this file or in command line or in usersdb file, then
# lt-cred-mech is default.
#
#no-auth
# TURN REST API flag.
# (Time Limited Long Term Credential)
# Flag that sets a special authorization option that is based upon authentication secret.
#
# This feature's purpose is to support "TURN Server REST API", see
# "TURN REST API" link in the project's page
# https://github.com/coturn/coturn/
#
# This option is used with timestamp:
#
# usercombo -> "timestamp:userid"
# turn user -> usercombo
# turn password -> base64(hmac(secret key, usercombo))
#
# This allows TURN credentials to be accounted for a specific user id.
# If you don't have a suitable id, the timestamp alone can be used.
# This option is just turning on secret-based authentication.
# The actual value of the secret is defined either by option static-auth-secret,
# or can be found in the turn_secret table in the database (see below).
#
# Read more about it:
# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf
#
# Be aware that use-auth-secret overrides some part of lt-cred-mech.
# Notice that this feature depends internally on lt-cred-mech, so if you set
# use-auth-secret then it enables internally automatically lt-cred-mech option
# like if you enable both.
#
# You can use only one of the to auth mechanisms in the same time because,
# both mechanism use the username and password validation in different way.
#
# This way be aware that you can't use both auth mechnaism in the same time!
# Use in config either the lt-cred-mech or the use-auth-secret
# to avoid any confusion.
#
listening-port=3478
fingerprint
lt-cred-mech
use-auth-secret
# 'Static' authentication secret value (a string) for TURN REST API only.
# If not set, then the turn server
# will try to use the 'dynamic' value in turn_secret table
# in user database (if present). The database-stored value can be changed on-the-fly
# by a separate program, so this is why that other mode is 'dynamic'.
#
#static-auth-secret=north
# Server name used for
# the oAuth authentication purposes.
# The default value is the realm name.
#
#server-name=blackdow.carleon.gov
# Flag that allows oAuth authentication.
#
#oauth
# 'Static' user accounts for long term credentials mechanism, only.
# This option cannot be used with TURN REST API.
# 'Static' user accounts are NOT dynamically checked by the turnserver process,
# so that they can NOT be changed while the turnserver is running.
#
#user=username1:key1
#user=username2:key2
# OR:
#user=username1:password1
#user=username2:password2
#
# Keys must be generated by turnadmin utility. The key value depends
# on user name, realm, and password:
#
# Example:
# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic
# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee
# ('0x' in the beginning of the key is what differentiates the key from
# password. If it has 0x then it is a key, otherwise it is a password).
#
# The corresponding user account entry in the config file will be:
#
#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee
# Or, equivalently, with open clear password (less secure):
#user=ninefingers:youhavetoberealistic
#
# SQLite database file name.
#
# Default file name is /var/db/turndb or /usr/local/var/db/turndb or
# /var/lib/turn/turndb.
#
#userdb=/var/db/turndb
# PostgreSQL database connection string in the case that we are using PostgreSQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL
# versions connection string format, see
# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING
# for 9.x and newer connection string formats.
#
#psql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> connect_timeout=30"
# MySQL database connection string in the case that we are using MySQL
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
#
# Optional connection string parameters for the secure communications (SSL):
# ca, capath, cert, key, cipher
# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the
# command options description).
#
# Use string format as below (space separated parameters, all optional):
#
#mysql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
# If you want to use in the MySQL connection string the password in encrypted format,
# then set in this option the MySQL password encryption secret key file.
#
# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format!
# If you want to use cleartext password then do not set this option!
#
# This is the file path which contain secret key of aes encryption while using password encryption.
#
#secret-key-file=/path/
# MongoDB database connection string in the case that we are using MongoDB
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html
#
#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"
# Redis database connection string in the case that we are using Redis
# as the user database.
# This database can be used for long-term credential mechanism
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
# Use string format as below (space separated parameters, all optional):
#
#redis-userdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used).
# This database keeps allocations status information, and it can be also used for publishing
# and delivering traffic and allocation event notifications.
# The connection string has the same parameters as redis-userdb connection string.
# Use string format as below (space separated parameters, all optional):
#
#redis-statsdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
# The default realm to be used for the users when no explicit
# origin/realm relationship was found in the database, or if the TURN
# server is not using any database (just the commands-line settings
# and the userdb file). Must be used with long-term credentials
# mechanism or with TURN REST API.
#
# Note: If default realm is not specified at all, then realm falls back to the host domain name.
# If domain name is empty string, or '(None)', then it is initialized to am empty string.
#
#realm=mycompany.org
# The flag that sets the origin consistency
# check: across the session, all requests must have the same
# main ORIGIN attribute value (if the ORIGIN was
# initially used by the session).
#
#check-origin-consistency
# Per-user allocation quota.
# default value is 0 (no quota, unlimited number of sessions per user).
# This option can also be set through the database, for a particular realm.
#
#user-quota=0
# Total allocation quota.
# default value is 0 (no quota).
# This option can also be set through the database, for a particular realm.
#
#total-quota=0
# Max bytes-per-second bandwidth a TURN session is allowed to handle
# (input and output network streams are treated separately). Anything above
# that limit will be dropped or temporary suppressed (within
# the available buffer limits).
# This option can also be set through the database, for a particular realm.
#
#max-bps=0
#
# Maximum server capacity.
# Total bytes-per-second bandwidth the TURN server is allowed to allocate
# for the sessions, combined (input and output network streams are treated separately).
#
# bps-capacity=0
# Uncomment if no UDP client listener is desired.
# By default UDP client listener is always started.
#
#no-udp
# Uncomment if no TCP client listener is desired.
# By default TCP client listener is always started.
#
#no-tcp
# Uncomment if no TLS client listener is desired.
# By default TLS client listener is always started.
#
#no-tls
# Uncomment if no DTLS client listener is desired.
# By default DTLS client listener is always started.
#
#no-dtls
# Uncomment if no UDP relay endpoints are allowed.
# By default UDP relay endpoints are enabled (like in RFC 5766).
#
#no-udp-relay
# Uncomment if no TCP relay endpoints are allowed.
# By default TCP relay endpoints are enabled (like in RFC 6062).
#
#no-tcp-relay
# Uncomment if extra security is desired,
# with nonce value having limited lifetime.
# By default, the nonce value is unique for a session,
# and has unlimited lifetime.
# Set this option to limit the nonce lifetime.
# It defaults to 600 secs (10 min) if no value is provided. After that delay,
# the client will get 438 error and will have to re-authenticate itself.
#
#stale-nonce=600
# Uncomment if you want to set the maximum allocation
# time before it has to be refreshed.
# Default is 3600s.
#
#max-allocate-lifetime=3600
# Uncomment to set the lifetime for the channel.
# Default value is 600 secs (10 minutes).
# This value MUST not be changed for production purposes.
#
#channel-lifetime=600
# Uncomment to set the permission lifetime.
# Default to 300 secs (5 minutes).
# In production this value MUST not be changed,
# however it can be useful for test purposes.
#
#permission-lifetime=300
# Certificate file.
# Use an absolute path or path relative to the
# configuration file.
#
#cert=/usr/local/etc/turn_server_cert.pem
# Private key file.
# Use an absolute path or path relative to the
# configuration file.
# Use PEM file format.
#
#pkey=/usr/local/etc/turn_server_pkey.pem
# Private key file password, if it is in encoded format.
# This option has no default value.
#
#pkey-pwd=...
# Allowed OpenSSL cipher list for TLS/DTLS connections.
# Default value is "DEFAULT".
#
#cipher-list="DEFAULT"
# CA file in OpenSSL format.
# Forces TURN server to verify the client SSL certificates.
# By default it is not set: there is no default value and the client
# certificate is not checked.
#
# Example:
#CA-file=/etc/ssh/id_rsa.cert
# Curve name for EC ciphers, if supported by OpenSSL
# library (TLS and DTLS). The default value is prime256v1,
# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+,
# an optimal curve will be automatically calculated, if not defined
# by this option.
#
#ec-curve-name=prime256v1
# Use 566 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh566
# Use 2066 bits predefined DH TLS key. Default size of the key is 1066.
#
#dh2066
# Use custom DH TLS key, stored in PEM format in the file.
# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file.
#
#dh-file=<DH-PEM-file-name>
# Flag to prevent stdout log messages.
# By default, all log messages are going to both stdout and to
# the configured log file. With this option everything will be
# going to the configured log only (unless the log file itself is stdout).
#
#no-stdout-log
# Option to set the log file name.
# By default, the turnserver tries to open a log file in
# /var/log, /var/tmp, /tmp and current directories directories
# (which open operation succeeds first that file will be used).
# With this option you can set the definite log file name.
# The special names are "stdout" and "-" - they will force everything
# to the stdout. Also, the "syslog" name will force everything to
# the system log (syslog).
# In the runtime, the logfile can be reset with the SIGHUP signal
# to the turnserver process.
#
#log-file=/var/tmp/turn.log
# Option to redirect all log output into system log (syslog).
#
syslog
# This flag means that no log file rollover will be used, and the log file
# name will be constructed as-is, without PID and date appendage.
# This option can be used, for example, together with the logrotate tool.
#
#simple-log
# Option to set the "redirection" mode. The value of this option
# will be the address of the alternate server for UDP & TCP service in form of
# <ip>[:<port>]. The server will send this value in the attribute
# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client.
# Client will receive only values with the same address family
# as the client network endpoint address family.
# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description.
# The client must use the obtained value for subsequent TURN communications.
# If more than one --alternate-server options are provided, then the functionality
# can be more accurately described as "load-balancing" than a mere "redirection".
# If the port number is omitted, then the default port
# number 3478 for the UDP/TCP protocols will be used.
# Colon (:) characters in IPv6 addresses may conflict with the syntax of
# the option. To alleviate this conflict, literal IPv6 addresses are enclosed
# in square brackets in such resource identifiers, for example:
# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 .
# Multiple alternate servers can be set. They will be used in the
# round-robin manner. All servers in the pool are considered of equal weight and
# the load will be distributed equally. For example, if we have 4 alternate servers,
# then each server will receive 25% of ALLOCATE requests. A alternate TURN server
# address can be used more than one time with the alternate-server option, so this
# can emulate "weighting" of the servers.
#
# Examples:
#alternate-server=1.2.3.4:5678
#alternate-server=11.22.33.44:56789
#alternate-server=5.6.7.8
#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to set alternative server for TLS & DTLS services in form of
# <ip>:<port>. If the port number is omitted, then the default port
# number 5349 for the TLS/DTLS protocols will be used. See the previous
# option for the functionality description.
#
# Examples:
#tls-alternate-server=1.2.3.4:5678
#tls-alternate-server=11.22.33.44:56789
#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
# Option to suppress TURN functionality, only STUN requests will be processed.
# Run as STUN server only, all TURN requests will be ignored.
# By default, this option is NOT set.
#
#stun-only
# Option to suppress STUN functionality, only TURN requests will be processed.
# Run as TURN server only, all STUN requests will be ignored.
# By default, this option is NOT set.
#
#no-stun
# This is the timestamp/username separator symbol (character) in TURN REST API.
# The default value is ':'.
# rest-api-separator=:
# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1).
# This is an extra security measure.
#
# (To avoid any security issue that allowing loopback access may raise,
# the no-loopback-peers option is replaced by allow-loopback-peers.)
#
# Allow it only for testing in a development environment!
# In production it adds a possible security vulnerability, so for security reasons
# it is not allowed using it together with empty cli-password.
#
#allow-loopback-peers
# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*).
# This is an extra security measure.
#
#no-multicast-peers
# Option to set the max time, in seconds, allowed for full allocation establishment.
# Default is 60 seconds.
#
#max-allocate-timeout=60
# Option to allow or ban specific ip addresses or ranges of ip addresses.
# If an ip address is specified as both allowed and denied, then the ip address is
# considered to be allowed. This is useful when you wish to ban a range of ip
# addresses, except for a few specific ips within that range.
#
# This can be used when you do not want users of the turn server to be able to access
# machines reachable by the turn server, but would otherwise be unreachable from the
# internet (e.g. when the turn server is sitting behind a NAT)
#
# Examples:
# denied-peer-ip=83.166.64.0-83.166.95.255
# allowed-peer-ip=83.166.68.45
# File name to store the pid of the process.
# Default is /var/run/turnserver.pid (if superuser account is used) or
# /var/tmp/turnserver.pid .
#
#pidfile="/var/run/turnserver.pid"
# Require authentication of the STUN Binding request.
# By default, the clients are allowed anonymous access to the STUN Binding functionality.
#
#secure-stun
# Mobility with ICE (MICE) specs support.
#
#mobility
# Allocate Address Family according
# If enabled then TURN server allocates address family according the TURN
# Client <=> Server communication address family.
# (By default coTURN works according RFC 6156.)
# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!!
#
#keep-address-family
# User name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current user ID to that user.
#
#proc-user=<user-name>
# Group name to run the process. After the initialization, the turnserver process
# will make an attempt to change the current group ID to that group.
#
#proc-group=<group-name>
# Turn OFF the CLI support.
# By default it is always ON.
# See also options cli-ip and cli-port.
#
#no-cli
#Local system IP address to be used for CLI server endpoint. Default value
# is 127.0.0.1.
#
#cli-ip=127.0.0.1
# CLI server port. Default is 5766.
#
#cli-port=5766
# CLI access password. Default is empty (no password).
# For the security reasons, it is recommended to use the encrypted
# for of the password (see the -P command in the turnadmin utility).
#
# Secure form for password 'qwerty':
#
#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a
#
# Or unsecure form for the same password:
#
#cli-password=qwerty
# Enable Web-admin support on https. By default it is Disabled.
# If it is enabled it also enables a http a simple static banner page
# with a small reminder that the admin page is available only on https.
#
#web-admin
# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1.
#
#web-admin-ip=127.0.0.1
# Web-admin server port. Default is 8080.
#
#web-admin-port=8080
# Web-admin server listen on STUN/TURN worker threads
# By default it is disabled for security resons! (Not recommended in any production environment!)
#
#web-admin-listen-on-workers
# Server relay. NON-STANDARD AND DANGEROUS OPTION.
# Only for those applications when we want to run
# server applications on the relay endpoints.
# This option eliminates the IP permissions check on
# the packets incoming to the relay endpoints.
#
#server-relay
# Maximum number of output sessions in ps CLI command.
# This value can be changed on-the-fly in CLI. The default value is 256.
#
#cli-max-output-sessions
# Set network engine type for the process (for internal purposes).
#
#ne=[1|2|3]
# Do not allow an TLS/DTLS version of protocol
#
#no-tlsv1
#no-tlsv1_1
#no-tlsv1_2
static-auth-secret=<secret-on-server-only>
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
realm=turn1.shineup.me
total-quota=200
stale-nonce=600
no-multicast-peers
no-loopback-peers
no-cli
simple-log
external-ip=178.208.64.62
listening-ip=0.0.0.0
relay-ip=178.208.64.62
min-port=49000
max-port=53999
@@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CAP_DIR="${SCHEME_DIR}/captures"
CFG_DIR="${SCHEME_DIR}/configs"
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
copy_optional_file() {
local remote_path="$1"
local local_path="$2"
if ssh "${REMOTE_HOST}" "test -f '$remote_path'"; then
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}"
else
mkdir -p "$(dirname "${local_path}")"
echo "missing: ${remote_path}" > "${local_path}.MISSING.txt"
fi
}
echo "[1/3] Обновляю инвентарь"
ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt"
ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt"
@@ -18,10 +30,14 @@ ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR}
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
echo "[2/3] Обновляю ключевые конфиги"
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
else
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
fi
copy_optional_file "/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
copy_optional_file "/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/shine-server.service"
copy_optional_file "/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/agent-memory.service"
echo "[3/3] Метка обновления"
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
+22 -3
View File
@@ -88,6 +88,9 @@
- `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации
- `entrypoint_edit_forbidden``TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
- `status_confirmed_target_must_be_status_action``STATUS_CONFIRMED` должен ссылаться на статусный блок
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error`
@@ -104,8 +107,13 @@
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)`формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_CHANNEL_META (70)`скрытый технический снимок профиля канала
- `TEXT_RATING (30)`target-based отзыв на конкретный блок
- `TEXT_REPOST (50)`формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала
- `TEXT_ENTRYPOINT (100)` — входная страница канала
- `TEXT_EXERCISE (110)` — line-based материал упражнения
- `TEXT_SERVICE (120)` — line-based материал услуги / процедуры
- `TEXT_COURSE (130)` — line-based материал курса
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
@@ -135,6 +143,17 @@
5. **USER_PARAM (type=4)**
- `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-форматы для каналов и вложений
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
@@ -173,7 +192,7 @@
### Изменение профиля канала
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`.
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
Текстовое содержимое body использует тот же формат полного снимка профиля:
+18 -1
View File
@@ -297,6 +297,7 @@
Первый целевой сценарий:
- `remote AddBlock via homeserver session`
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
То есть телефон без локального `blockchain.key` может:
@@ -336,6 +337,12 @@
- запрос должен быть подписан и `session key`, и `client key`;
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
Для звонковых сигналов `signalType = call_*` правило строже:
- `clientSignatureB64` обязателен;
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
- это правило действует и для `single_session`, и для `all_sessions`.
### Запрос в одну сессию
```json
@@ -366,11 +373,20 @@
"ok": true,
"payload": {
"deliveredCount": 1,
"deliveredSessionIds": ["sess-hs-001"]
"deliveredSessionIds": ["sess-hs-001"],
"deliveredWsSessions": 1,
"deliveredFcmSessions": 0,
"deliveredWebPushSessions": 0
}
}
```
Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае:
- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket;
- `deliveredWebPushSessions` — сколько offline-сессий получили web-push;
- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы.
### Событие на принимающей стороне
```json
@@ -428,6 +444,7 @@
- `404 / USER_NOT_FOUND` — логин адресата не найден.
- `400 / BAD_DATA` — сервер не смог обработать `data`.
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
+36 -3
View File
@@ -14,11 +14,13 @@
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки.
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
5. `ListGroupChats200` — отдает список групповых чатов типа `200`.
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
@@ -192,6 +194,7 @@
"text": "текущая версия",
"likesCount": 12,
"repliesCount": 3,
"ratingsCount": 2,
"versionsTotal": 4,
"versions": [
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
@@ -248,6 +251,36 @@
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит:
- `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`;
- `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`.
- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений:
- `TEXT_REPLY`;
- `TEXT_RATING`.
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
---
## 4) GetPersonalDiary
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
- Вызов доступен только владельцу дневника.
- Сообщения в ответе строятся из блоков `STATUS_ACTION`.
- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие.
### Request
```json
{
"op": "GetPersonalDiary",
"requestId": "req-4",
"payload": {
"login": "Alice",
"limit": 200,
"sort": "asc"
}
}
```
---
+1
View File
@@ -44,6 +44,7 @@
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
@@ -11,14 +11,16 @@
- `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`).
## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
- `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя.
- `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED.
## Примечание
+1 -1
View File
@@ -9,7 +9,7 @@
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
- `msg_type=1`
- `subType=70`
- `subType=90`
- `TEXT_CHANNEL_META`
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
+31 -3
View File
@@ -1,6 +1,6 @@
# TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования.
TEXT-тип хранит сообщения, материалы и редактирования.
## Подтипы
@@ -21,24 +21,52 @@ TEXT-тип хранит сообщения и редактирования.
- target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30``TEXT_REPOST`
5. `subType=30``TEXT_RATING`
- target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала.
6. `subType=50``TEXT_REPOST`
- репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
6. `subType=70``TEXT_CHANNEL_META`
7. `subType=90``TEXT_CHANNEL_META`
- скрытый технический снимок профиля канала;
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
- не отображается как обычное сообщение ленты;
- применяется сервером к текущему состоянию канала.
8. `subType=100``TEXT_ENTRYPOINT`
- входная страница канала;
- line-based сообщение с тем же body, что у `TEXT_POST`;
- не редактируется через `TEXT_EDIT_POST`: новая версия создаётся новым `TEXT_ENTRYPOINT`.
9. `subType=110``TEXT_EXERCISE`
- line-based материал упражнения;
- использует тот же body, что у `TEXT_POST`.
10. `subType=120``TEXT_SERVICE`
- line-based материал услуги / процедуры;
- использует тот же body, что у `TEXT_POST`.
11. `subType=130``TEXT_COURSE`
- line-based материал курса;
- использует тот же body, что у `TEXT_POST`.
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
Важно:
- `TEXT_EDIT_POST` — технический edit для line-based сообщений канала;
- `TEXT_EDIT_REPLY` — технический edit для reply-сообщений;
- `TEXT_ENTRYPOINT` через `TEXT_EDIT_POST` не редактируется.
## Пустой text в edit
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
@@ -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`
- `subType = 70`
- `subType = 90`
- `msgVersion = 1`
- 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
- Базовый коммит-ориентир: `391b18a`.
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
+7 -5
View File
@@ -17,15 +17,17 @@
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`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` для видео и крупных изображений.
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` для профиля канала.
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`.
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)
Журнал изменений документации.
## Смежная документация
+8 -2
View File
@@ -10,6 +10,7 @@ import {
handleIncomingCallInvite,
handleIncomingCallPush,
handleIncomingCallSignal,
handleIncomingCallSignalViaSendSignal,
handleStopCallPush,
setCallDebugReporter,
startDebugConnectionAsInitiator,
@@ -1210,13 +1211,14 @@ async function init() {
if (added && isIncomingForCurrent && !payload.backlog) {
const parsedText = parseDmTechBlocks(text);
const displayText = String(parsedText.displayText || '');
const suppressHiddenNotification = parsedText.callSummary?.status === 'completed';
if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') {
} else if (Notification.permission === 'granted' && !suppressHiddenNotification) {
try {
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
} catch {}
} else {
} else if (!suppressHiddenNotification) {
void playDmSignal({ extended: true });
}
}
@@ -1313,6 +1315,10 @@ async function init() {
try { await handleIncomingCallSignal(evt); } catch {}
});
authService.onEvent('IncomingSignal', async (evt) => {
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
});
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
try {
const p = evt?.payload || {};
@@ -314,6 +314,9 @@ export function openArweaveAttachmentManager({
let balanceInfo = null;
let autoOpenedFileDialog = false;
const isAvatarMode = String(mode || '') === 'avatar';
if (isAvatarMode && !String(uploadTransport || '').trim()) {
selectedUploadTransport = 'turbo';
}
const historyPurposeMode = String(historyPurpose || '').trim();
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
@@ -592,8 +595,8 @@ export function openArweaveAttachmentManager({
<div class="form-actions-grid">
<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>
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="history-top">Использовать журнал загрузок</button>'}
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing-top">Использовать существующий в Arweave файл</button>'}
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
</div>
${turboMode
? `
@@ -860,12 +863,14 @@ export function openArweaveAttachmentManager({
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<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>
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
${isAvatarMode ? '' : `
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
<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" />
Добавить превью
</label>
@@ -909,8 +914,8 @@ export function openArweaveAttachmentManager({
});
checkBtn?.addEventListener('click', async () => {
const txId = String(txEl?.value || '').trim();
const name = String(nameEl?.value || '').trim() || 'arweave-file';
const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked;
const name = isAvatarMode ? 'Аватар' : (String(nameEl?.value || '').trim() || 'arweave-file');
const wantsPreview = !isAvatarMode && previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked;
const previewTxId = String(previewTxEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setText(errorEl, 'Некорректный Transaction ID Arweave.');
@@ -940,6 +945,7 @@ export function openArweaveAttachmentManager({
ar: txId,
preview,
uploadedAtMs: Date.now(),
purpose: isAvatarMode ? 'avatar' : 'attachment',
});
metaEl.innerHTML = `
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
@@ -950,7 +956,7 @@ export function openArweaveAttachmentManager({
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение';
addBtn.textContent = isAvatarMode ? 'Выбрать аватар' : (historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение');
addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode }));
metaEl.append(addBtn);
} catch (error) {
+145 -18
View File
@@ -2,17 +2,23 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
validateChannelDisplayName,
} 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 { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) {
try {
@@ -48,13 +54,38 @@ function renderAvatarPreview(slot, avatar, title) {
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 }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
title: 'Создать канал',
title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
@@ -62,24 +93,28 @@ export function render({ navigate }) {
const form = document.createElement('form');
form.className = 'card stack';
form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Разрешены только латинские буквы, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.</p>
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
<div class="channel-create-avatar-head">
<div id="channel-avatar-preview"></div>
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар"></button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
</div>
<label for="channel-name">Техническое имя канала</label>
<label for="channel-name">Технический логин канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
<div class="meta-muted channel-link-preview" id="channel-link-preview">&nbsp;</div>
<div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-title">Человекочитаемое имя</label>
<label for="channel-title">Как канал будет виден пользователям</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div>
<div id="channel-avatar-preview"></div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<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 descriptionEl = form.querySelector('#channel-description');
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
const avatarStatusEl = form.querySelector('#channel-avatar-status');
const avatarBtn = form.querySelector('#channel-avatar-btn');
const avatarRemoveBtn = form.querySelector('#channel-avatar-remove');
const linkPreviewEl = form.querySelector('#channel-link-preview');
const nameErrorEl = form.querySelector('#channel-name-error');
const titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter');
@@ -108,6 +146,58 @@ export function render({ navigate }) {
let submitInFlight = false;
let selectedAvatar = null;
let ownerBlockchainName = '';
let avatarAvailabilityLabel = '';
let avatarAvailabilityTxId = '';
const renderAvatarStatus = (nameValue, titleValue) => {
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleValue || nameValue);
avatarRemoveBtn.hidden = !selectedAvatar?.ar;
if (!selectedAvatar?.ar) {
avatarStatusEl.innerHTML = '<p class="channel-avatar-status-text">Аватар канала не установлен.</p>';
return;
}
const txId = String(selectedAvatar.ar || '').trim();
const shortTxId = shortAvatarBlockchainAddress(txId);
const isPending = avatarAvailabilityLabel === 'pending' || avatarAvailabilityTxId !== txId;
avatarStatusEl.innerHTML = `
<p class="channel-avatar-status-text">Аватар канала</p>
<p class="channel-avatar-status-line">Адрес аватара в блокчейне Arweave <span class="channel-avatar-txid">${shortTxId}</span><span class="channel-avatar-status-pending">${isPending ? ', ещё не обновился в блокчейне.' : ''}</span></p>
`;
};
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.innerHTML = '&nbsp;';
return;
}
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
});
linkPreviewEl.innerHTML = `Прямая ссылка: <a href="${directUrl}" target="_blank" rel="noopener noreferrer">${directUrl}</a>`;
};
const refreshAvatarAvailability = async () => {
const txId = String(selectedAvatar?.ar || '').trim();
avatarAvailabilityTxId = txId;
avatarAvailabilityLabel = txId ? 'pending' : '';
renderAvatarStatus(nameEl.value, titleEl.value);
if (!txId) return;
try {
const availability = await getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: selectedAvatar,
});
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = String(availability?.status || '').trim().toLowerCase();
} catch {
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = 'pending';
}
renderAvatarStatus(nameEl.value, titleEl.value);
};
const setBusy = (busy) => {
submitInFlight = !!busy;
@@ -125,7 +215,7 @@ export function render({ navigate }) {
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
nameErrorEl.textContent = '';
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
@@ -134,7 +224,8 @@ export function render({ navigate }) {
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized);
renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return {
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);
descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => {
try {
selectedAvatar = await openArweaveAttachmentManager({
const nextAvatar = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (nextAvatar?.ar) {
selectedAvatar = nextAvatar;
void refreshAvatarAvailability();
}
updateValidation();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
}
});
avatarRemoveBtn.addEventListener('click', () => {
if (!selectedAvatar?.ar) return;
const confirmed = window.confirm('Убрать аватар и сделать канал без аватара?');
if (!confirmed) return;
selectedAvatar = null;
avatarAvailabilityLabel = '';
avatarAvailabilityTxId = '';
updateValidation();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (submitInFlight) return;
@@ -174,6 +287,11 @@ export function render({ navigate }) {
}
const check = updateValidation();
if (check.name && /^[0-9]+$/.test(check.name)) {
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
nameEl.focus();
return;
}
if (!check.ok) return;
setBusy(true);
@@ -206,5 +324,14 @@ export function render({ navigate }) {
screen.append(form);
nameEl.focus();
updateValidation();
void authService.getUser(String(state.session.login || '').trim())
.then((user) => {
ownerBlockchainName = String(user?.blockchainName || '').trim();
renderChannelLinkPreview(nameEl.value);
})
.catch(() => {
ownerBlockchainName = '';
renderChannelLinkPreview(nameEl.value);
});
return screen;
}
+132 -20
View File
@@ -24,6 +24,12 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const pendingReactionActions = new Set();
const pendingThreadScroll = new Map();
@@ -209,6 +215,46 @@ function resolveChannelDisplayName(channelSelector) {
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
}
function resolveChannelHeadingFromNode(node) {
const info = node?.channelInfo;
const ownerBch = String(info?.ownerBlockchainName || '').trim();
const rootBlockNumber = Number(info?.channelRoot?.blockNumber);
if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return '';
if (rootBlockNumber === 0) return 'История пользователя';
const label = resolveChannelDisplayName({
ownerBlockchainName: ownerBch,
channelRootBlockNumber: rootBlockNumber,
channelRootBlockHash: '0',
});
const slashIndex = label.indexOf('/');
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim();
if (!channelName) return 'Сообщение в канале';
return `Сообщение в канале ${channelName}`;
}
function getChannelMessageTypeMeta(msgSubType) {
switch (Number(msgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE:
return { label: 'Упражнение', actionable: true };
case MSG_SUBTYPE_TEXT_SERVICE:
return { label: 'Услуга', actionable: true };
case MSG_SUBTYPE_TEXT_COURSE:
return { label: 'Курс', actionable: true };
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
return { label: 'Оглавление' };
default:
return null;
}
}
function isEditableAsChannelPostSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE;
}
function extractChannelContextFromThreadPayload(payload) {
const focusInfo = payload?.focus?.channelInfo;
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
@@ -417,10 +463,10 @@ function openBlockchainDetailsModal(details) {
function resolveNodeText(node) {
return firstNonEmptyText(
latestVersionText(node?.versions),
node?.text,
node?.message,
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');
root.innerHTML = `
<div class="modal" id="thread-reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<h3 class="modal-title">${title}</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
@@ -486,7 +541,7 @@ function openReplyModal({ onSubmit, navigate }) {
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
errorEl.textContent = emptyError;
return;
}
@@ -499,7 +554,7 @@ function openReplyModal({ onSubmit, navigate }) {
close();
} catch (error) {
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) {
const card = document.createElement('article');
card.className = 'card stack thread-node-card channel-message-card';
card.classList.add('is-counters-visible');
const author = node?.authorLogin || 'автор';
const versions = Array.isArray(node?.versions) ? node.versions : [];
@@ -703,11 +757,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const msgSubType = Number(node?.msgSubType || 0);
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
const parsedText = parseMessageAttachments(text);
if (isRating) card.classList.add('is-rating');
card.classList.add('is-counters-visible');
const headingText = String(heading || '').trim();
if (headingText) {
@@ -720,6 +778,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const avatar = createThreadAvatar(author);
@@ -727,13 +787,16 @@ function renderNodeCard(node, heading, handlers, localNumber) {
authorBlock.className = 'channel-message-author';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const titleMain = document.createElement('div');
titleMain.className = 'author-line-main';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = author;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${localNumber}`;
title.append(loginEl, numberEl);
titleMain.append(loginEl, numberEl);
title.append(titleMain);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
@@ -755,6 +818,25 @@ function renderNodeCard(node, heading, handlers, localNumber) {
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
const typeMeta = getChannelMessageTypeMeta(node?.msgSubType);
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
handlers.onStatusAction(node);
});
} else {
typeButton.classList.add('is-static');
typeButton.disabled = true;
}
headRow.append(typeButton);
}
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
@@ -775,13 +857,19 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(deleted);
return card;
} else {
card.append(authorTile);
card.append(headRow);
if (parsedText.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: node?.createdAtMs,
}));
}
if (isRating) {
const ratingBadge = document.createElement('span');
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
ratingBadge.textContent = 'Оценка';
card.append(ratingBadge);
}
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = parsedText.text;
@@ -855,6 +943,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
const ratingButton = document.createElement('button');
ratingButton.type = 'button';
ratingButton.className = 'channel-action-item thread-rating-btn';
ratingButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оценка</span>
<span class="channel-action-counter">${ratings}</span>
`;
setActionTitle(ratingButton, 'Оценка');
ratingButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate: handlers.navigate,
mode: 'rating',
onSubmit: async (textValue) => handlers.onRating(target, textValue),
});
});
const shareButton = document.createElement('button');
shareButton.type = 'button';
@@ -872,7 +978,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton);
actions.append(likeButton, replyButton, ratingButton, shareButton);
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
@@ -959,7 +1065,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
normalized.forEach((branch, index) => {
try {
const nodeNumber = nextNumber();
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
row.classList.add('thread-node-level');
row.style.setProperty('--depth', String(Math.min(depth, 4)));
wrap.append(row);
@@ -1104,6 +1210,15 @@ export function render({ navigate, route }) {
showStatus('');
rerender();
},
onRating: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Оценка отправлена');
showStatus('');
rerender();
},
onRepost: async (target) => {
const { login, storagePwd } = requireSigningSession();
const feed = await authService.listSubscriptionsFeed(login, 1000);
@@ -1326,12 +1441,9 @@ export function render({ navigate, route }) {
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'История выше (на что это ответ)';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
});
}
@@ -1350,7 +1462,7 @@ export function render({ navigate, route }) {
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = 'Ответы';
descendantsTitle.textContent = 'Ответы и оценки';
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
@@ -1358,7 +1470,7 @@ export function render({ navigate, route }) {
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ответов пока нет.';
empty.textContent = 'Ответов и оценок пока нет.';
descendantsWrap.append(empty);
}
+583 -36
View File
@@ -37,6 +37,23 @@ import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_STORIES = 0;
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 pendingScrollByRoute = new Map();
@@ -287,6 +304,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) {
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
@@ -303,7 +396,19 @@ function createChannelAvatarElement(channel, size = 72) {
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');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
@@ -313,9 +418,14 @@ function openChannelMetaDetailsModal({ title = 'Данные канала', chan
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
</div>
<div id="about-channel-avatar-slot"></div>
<p><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}</strong></p>
<p class="meta-muted">${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}</p>
<p class="meta-muted">${escapeHtml(channel.description || 'Описание не задано.')}</p>
<div class="channel-meta-details-grid">
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<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>
</div>
</div>
@@ -337,6 +447,7 @@ function openAboutChannelModal(channel, options = {}) {
channel,
canEdit: options.canEdit === true,
onEdit: options.onEdit,
changedAtMs: Number(channel?.metaUpdatedAtMs || 0),
});
}
@@ -541,13 +652,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');
root.innerHTML = `
<div class="modal" id="reply-modal">
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<h3 class="modal-title">${title}</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<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>
@@ -584,7 +704,7 @@ function openReplyModal({ onSubmit, navigate }) {
const text = String(textEl?.value || '').trim();
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
errorEl.textContent = emptyError;
return;
}
@@ -597,7 +717,7 @@ function openReplyModal({ onSubmit, navigate }) {
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
errorEl.textContent = toUserMessage(error, submitError);
}
});
@@ -626,6 +746,191 @@ function openReplyModal({ onSubmit, navigate }) {
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 }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
@@ -709,7 +1014,16 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
<p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="draft-attachments" id="channel-message-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<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>
</div>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
@@ -720,6 +1034,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
`;
const textEl = root.querySelector('#channel-message-text');
const typeEl = root.querySelector('#channel-message-type');
const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
@@ -730,6 +1045,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
if (typeEl) typeEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -743,6 +1059,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
const msgSubType = Number(typeEl?.value || 10);
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
@@ -752,7 +1069,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(composeMessageWithAttachments(body, attachments));
await onSubmit({
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
@@ -880,6 +1200,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
const hasRef = !!(messageBch && blockNumber != null && blockHash);
const resolvedText = resolveMessageText(message);
const msgSubType = Number(message?.msgSubType || 0);
const isStatusAction = isStatusActionSubType(msgSubType);
const messageRef = hasRef
? {
blockchainName: messageBch,
@@ -895,15 +1217,18 @@ function mapApiMessageToPost(message, selector, localNumber) {
return {
localNumber,
authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')),
versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
rawMessage: message,
msgSubType: Number(message?.msgSubType || 0),
msgSubType,
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction,
isStatusAction,
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
blockchainName: String(message.targetBlockchainName).trim(),
@@ -911,6 +1236,11 @@ function mapApiMessageToPost(message, selector, localNumber) {
blockHash: normalizeMessageHash(message?.targetBlockHash),
}
: 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) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
};
@@ -939,6 +1269,45 @@ async function loadFromApi(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) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
@@ -1181,15 +1550,14 @@ function renderChannelMetaEventCard(event) {
const card = document.createElement('article');
card.className = 'card channel-system-event-card';
const label = event.kind === 'created'
? `Создан канал ${event.title || event.name || ''}`.trim()
: 'Изменён профиль канала';
? 'Создан канал'
: 'Изменено описание канала';
card.innerHTML = `
<span>${escapeHtml(label)}</span>
<small>${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')}</small>
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
`;
card.addEventListener('click', () => {
openChannelMetaDetailsModal({
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён',
title: label,
channel: {
name: event.name,
displayTitle: event.title,
@@ -1199,6 +1567,7 @@ function renderChannelMetaEventCard(event) {
avaSize: event.avaSize,
ownerName: event.ownerName,
},
changedAtMs: event.timestampMs,
});
});
return card;
@@ -1209,6 +1578,9 @@ function renderPostCard(post, {
selector,
onToggleLike,
onReply,
onRating,
onStatusAction,
onOpenEntrypointMenu,
onRepost,
onShare,
onEdit,
@@ -1217,6 +1589,8 @@ function renderPostCard(post, {
const card = document.createElement('article');
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');
authorTile.type = 'button';
@@ -1226,9 +1600,13 @@ function renderPostCard(post, {
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const titleMain = document.createElement('div');
titleMain.className = 'author-line-main';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = post.authorLogin;
@@ -1236,12 +1614,12 @@ function renderPostCard(post, {
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`;
titleMain.append(loginEl, numberEl);
title.append(titleMain);
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
title.append(loginEl, numberEl);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
@@ -1260,6 +1638,31 @@ function renderPostCard(post, {
}
authorBlock.append(title, timestamp);
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) => {
event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim();
@@ -1287,13 +1690,38 @@ function renderPostCard(post, {
card.append(deleted);
return card;
} else {
card.append(authorTile);
card.append(headRow);
if (parsedBody.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer,
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 targetText = String(post.targetText || '').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');
body.className = 'channel-message-body';
body.textContent = parsedBody.text;
@@ -1357,9 +1785,27 @@ function renderPostCard(post, {
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
actions.append(likeButton, replyButton);
actions.append(likeButton, replyButton, ratingButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
@@ -1377,7 +1823,7 @@ function renderPostCard(post, {
});
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');
originalBtn.type = 'button';
originalBtn.className = 'channel-action-item';
@@ -1506,6 +1952,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onRating: handlers.onRating,
onStatusAction: handlers.onStatusAction,
onOpenEntrypointMenu: handlers.onOpenEntrypointMenu,
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
@@ -1519,7 +1968,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ждем ваших начинаний';
empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
feed.append(empty);
}
@@ -1533,7 +1984,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) {
if (channelData.isDiary) {
screen.append(feed, backButton);
} else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(actionButton, feed, backButton);
@@ -1541,7 +1994,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
return () => {
// noop
};
@@ -1582,13 +2035,20 @@ export function render({ navigate, route }) {
const header = renderHeader({
title: '',
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) {
channelHeaderButton.classList.add('channel-header-route-btn');
channelHeaderButton.disabled = true;
}
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
@@ -1651,6 +2111,47 @@ export function render({ navigate, route }) {
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 feed = await authService.listSubscriptionsFeed(login, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
@@ -1723,7 +2224,7 @@ export function render({ navigate, route }) {
}
};
const onAddPost = async (bodyText) => {
const onAddPost = async (bodyText, msgSubType = 10) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
@@ -1734,6 +2235,7 @@ export function render({ navigate, route }) {
storagePwd,
channel: activeSelector,
text: bodyText,
msgSubType,
});
pendingScrollByRoute.set(routeKey, '__LAST__');
@@ -1744,7 +2246,8 @@ export function render({ navigate, route }) {
const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
const isDiaryEdit = isDiarySelector(activeSelector);
if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockEditMessage({
@@ -1752,8 +2255,8 @@ export function render({ navigate, route }) {
storagePwd,
message: messageRef,
text,
isChannelPost: true,
channel: activeSelector,
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
softHaptic(12);
showToast('Сообщение обновлено');
@@ -1791,13 +2294,25 @@ export function render({ navigate, route }) {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
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) {
channelHeaderButton.textContent = titleLabel;
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true,
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
onEdit: () => openEditChannelModal({
channel: apiData.channel,
onSave: onEditChannelMeta,
@@ -1805,15 +2320,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();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onAddMessage: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
onSubmit: async (bodyText) => {
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText);
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
@@ -1837,6 +2363,27 @@ export function render({ navigate, route }) {
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) => {
try {
await onRepost(messageRef);
+63 -2
View File
@@ -21,6 +21,8 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Дневник';
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
@@ -698,11 +700,64 @@ function pullCreateSuccessFlash() {
}
}
function mapApiFeed(feed, notificationsState) {
function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) {
const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
if (!messages.length) return null;
const ownerBlockchainName = String(
diaryPayload?.channel?.ownerBlockchainName
|| ownRows[0]?.channel?.ownerBlockchainName
|| ''
).trim();
if (!ownerBlockchainName) return null;
const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim();
const lastMessage = messages[messages.length - 1] || null;
const rowId = 'own-diary';
index[rowId] = { diary: true, payload: diaryPayload };
return {
id: rowId,
route: makeShineChannelRoute({
ownerLogin,
ownerBlockchainName,
channelName: DIARY_CHANNEL_NAME,
}),
ownerName: ownerLogin || 'я',
ownerBlockchainName,
channelRootBlockNumber: 0,
channelRootBlockHash: '0',
avatar: 'Д',
avaAr: '',
title: DIARY_DISPLAY_NAME,
technicalLabel: 'Виртуальная лента Дневника',
channelName: DIARY_CHANNEL_NAME,
displayTitle: DIARY_DISPLAY_NAME,
channelDescription: 'История действий по упражнениям, услугам и курсам',
channelTypeCode: 900,
channelTypeVersion: 1,
messagePreview: String(lastMessage?.text || '').trim()
? cleanChannelMessagePreview(lastMessage?.text)
: 'Новая запись в Дневнике',
messagesCount: messages.length,
unreadCount: 0,
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
isOwnChannel: true,
isSubscribed: false,
notificationsEnabled: notificationsState[rowId] === true,
pending: false,
};
}
function mapApiFeed(feed, notificationsState, diaryPayload = null) {
const index = {};
const ownChannels = (feed?.ownedChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
const diaryChannel = diaryPayload
? buildDiaryChannelRow(diaryPayload, feed?.ownedChannels || [], notificationsState, index)
: null;
if (diaryChannel) ownChannels.unshift(diaryChannel);
const followedUserChannels = (feed?.followedUsersChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
@@ -1126,7 +1181,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed, listState.notificationsState);
let diaryPayload = null;
try {
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
} catch {
diaryPayload = null;
}
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
listState.channels = toListModel(groups);
setChannelsFeed(feed, groups.index);
+48 -37
View File
@@ -245,6 +245,8 @@ function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
@@ -255,7 +257,9 @@ function openChatActionsMenu({
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
@@ -303,6 +307,14 @@ function openChatActionsMenu({
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
close();
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -440,12 +452,12 @@ function buildCallStatusText(callSummary) {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Не дозвонился: абонент занят';
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
return 'Не дозвонился';
if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонок отклонён';
return 'Звонок не завершён';
}
function resolveEffectiveReadState(messages, msg) {
@@ -594,20 +606,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon';
callIcon.textContent = '☎';
callIcon.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming');
const callIconPhone = document.createElement('span');
callIconPhone.className = 'bubble-call-icon-phone';
callIconPhone.textContent = '✆';
const callIconArrow = document.createElement('span');
callIconArrow.className = 'bubble-call-icon-arrow';
callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓';
callIcon.append(callIconPhone, callIconArrow);
const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title';
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
callTitle.textContent = 'Звонок';
callHead.append(callIcon, callTitle);
const callMetaLine = document.createElement('div');
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
const callMetaLine = document.createElement('span');
callMetaLine.className = 'bubble-call-line';
callMetaLine.classList.add(
parsedText.callSummary.status === 'completed'
? 'bubble-call-line--success'
: 'bubble-call-line--plain',
);
const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus;
callCard.append(callHead, callMetaLine);
callHead.append(callIcon, callTitle, callMetaLine);
callCard.append(callHead);
bubble.append(callCard);
}
@@ -789,9 +815,9 @@ export function render({ navigate, route }) {
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
};
const handleStartCall = async () => {
const handleStartCall = async (mode = 'audio') => {
try {
await startOutgoingCall(chatId);
await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
@@ -855,10 +881,10 @@ export function render({ navigate, route }) {
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
iconNode: createHeaderPhoneHandsetIcon(),
onClick: handleStartCall,
onClick: () => handleStartCall('audio'),
},
{
label: '',
label: '',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -866,7 +892,9 @@ export function render({ navigate, route }) {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall,
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -963,7 +991,7 @@ export function render({ navigate, route }) {
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование"></button>
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph"></span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
@@ -1427,23 +1455,6 @@ export function render({ navigate, route }) {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
});
// Enter — отправить; Shift+Enter и Ctrl+Enter — перенос строки.
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.ctrlKey) {
return;
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
syncComposerLayout();
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
+5
View File
@@ -4,6 +4,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
import {
@@ -251,6 +252,10 @@ export function render({ navigate }) {
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
+20 -4
View File
@@ -18,6 +18,7 @@ import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
isUserAlreadyExistsSolanaError,
registerUserOnSolana,
} from '../services/solana-register-service.js';
@@ -311,6 +312,16 @@ export function render({ navigate }) {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.');
// Перед on-chain регистрацией перепроверяем логин ещё раз:
// между экраном выбора логина и оплатой его мог занять кто-то другой.
const loginStateBeforeRegister = await checkLoginExistsOnSolana({
login: state.registrationDraft.login,
solanaEndpoint: state.entrySettings.solanaServer,
});
if (loginStateBeforeRegister?.exists) {
throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
}
// Регистрация на Solana (смарт контракт)
submitButton.textContent = 'Регистрация в Solana...';
let registrationTxId = '';
@@ -325,15 +336,20 @@ export function render({ navigate }) {
registrationTxId = String(registrationResult?.signature || '').trim();
} catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError);
// Пользователь уже зарегистрирован в Solana — продолжаем
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) {
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
if (solanaMsg.includes('already') || isUserAlreadyExistsSolanaError(solanaError)) {
throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
}
if (isSolanaRpcUnavailableError(solanaError)) {
throw new Error('Нет связи с сервером Solana. Попробуйте ещё раз позже.');
}
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
}
renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
const message = isSolanaRpcUnavailableError(error)
? 'Нет связи с сервером Solana. Попробуйте ещё раз позже.'
: toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
+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 PRETTY_PATHS = new Map([
@@ -90,9 +90,11 @@ export function getRoute() {
};
const isLikelyBlockNumber = (value) => /^[0-9]+$/.test(String(value || '').trim());
const shineLogin = parseShineRootSegment(pageId);
if (shineLogin) {
const section = decodePart(segments[1] || '').toLowerCase();
const shineRoute = parseShineRouteParts(segments);
if (shineRoute?.entity) {
const shineLogin = shineRoute.entity;
const baseOffset = Number(shineRoute.offset || 0);
const section = decodePart(segments[baseOffset] || '').toLowerCase();
if (!section) {
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
}
@@ -100,7 +102,7 @@ export function getRoute() {
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
}
if (section === 'channels') {
const sub = decodePart(segments[2] || '').toLowerCase();
const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
if (sub === 'owned') {
return {
pageId: 'channels-list',
@@ -118,12 +120,12 @@ export function getRoute() {
params: { mode: 'feed', login: shineLogin, scope: 'all' },
};
}
if (segments.length === 2 && isLikelyBlockNumber(segments[1])) {
if (segments.length === baseOffset + 1 && isLikelyBlockNumber(segments[baseOffset])) {
return {
pageId: 'channel-thread-view',
params: {
messageBlockchainName: shineLogin,
messageBlockNumber: segments[1] || '',
messageBlockNumber: segments[baseOffset] || '',
messageBlockHash: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
@@ -131,10 +133,10 @@ export function getRoute() {
},
};
}
if (segments.length >= 2) {
if (segments.length >= baseOffset + 1) {
const ownerBlockchainName = shineLogin;
const channelName = decodePart(segments[1] || '');
const messageBlockNumber = segments[2] || '';
const channelName = decodePart(segments[baseOffset] || '');
const messageBlockNumber = segments[baseOffset + 1] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) {
return {
pageId: 'channel-thread-view',
+19 -1
View File
@@ -278,12 +278,25 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
openAttachmentViewer({ item, url, kind });
});
const bindImageLayout = (img) => {
img.addEventListener('load', () => {
const naturalWidth = Number(img.naturalWidth || 0);
const naturalHeight = Number(img.naturalHeight || 0);
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
img.classList.toggle('is-landscape', isLandscape);
frame.classList.toggle('is-landscape', isLandscape);
slide.classList.toggle('has-landscape-media', isLandscape);
slide.parentElement?.classList.toggle('has-landscape-media', isLandscape);
}, { once: true });
};
if (kind === 'image') {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = url;
img.alt = item.name;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
frame.append(img);
} else if (previewUrl) {
@@ -292,6 +305,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
img.src = previewUrl;
img.alt = `${item.name} preview`;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
@@ -351,6 +365,7 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div');
counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
const render = () => {
stopVideos(wrap);
@@ -358,6 +373,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
viewport.classList.remove('has-landscape-media');
slide.className = 'message-attachment-slide';
slide.dataset.unavailable = '0';
slide.replaceChildren();
slide.append(kind === 'file'
@@ -365,7 +382,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0;
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) => {
+166 -7
View File
@@ -47,14 +47,29 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_REPOST = 30;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_REPOST = 50;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130;
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
const MSG_SUBTYPE_STATUS_LEARNED = 20;
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
const MSG_SUBTYPE_STATUS_STARTED = 120;
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@@ -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({
lineCode,
prevLineNumber,
@@ -1427,6 +1503,13 @@ export class AuthService {
return response.payload || {};
}
async getPersonalDiary(login, limit = 200, sort = 'asc') {
const payload = { login: String(login || '').trim(), limit, sort };
const response = await this.ws.request('GetPersonalDiary', payload);
if (response.status !== 200) throw opError('GetPersonalDiary', response);
return response.payload || {};
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
@@ -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 }) {
const cleanLogin = String(login || '').trim();
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();
if (!cleanLogin) throw new Error('Missing login');
const cleanText = String(text || '').trim();
const cleanSubType = Number(msgSubType || MSG_SUBTYPE_TEXT_POST);
const allowedTextSubTypes = new Set([
MSG_SUBTYPE_TEXT_POST,
MSG_SUBTYPE_TEXT_ENTRYPOINT,
MSG_SUBTYPE_TEXT_EXERCISE,
MSG_SUBTYPE_TEXT_SERVICE,
MSG_SUBTYPE_TEXT_COURSE,
]);
if (!allowedTextSubTypes.has(cleanSubType)) {
throw new Error('Unsupported channel text subtype');
}
const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber);
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
@@ -2238,7 +2397,7 @@ export class AuthService {
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_POST,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
@@ -2631,8 +2790,8 @@ export class AuthService {
}
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
return response.payload || {};
}
File diff suppressed because it is too large Load Diff
+90 -6
View File
@@ -2,6 +2,7 @@ import {
acceptIncomingCall,
declineIncomingCall,
hangupActiveCall,
setCameraEnabled,
setMicMuted,
subscribeCallState,
toggleSpeakerMode,
@@ -15,10 +16,16 @@ let panelEl = null;
let headerEl = null;
let titleEl = null;
let statusEl = null;
let mediaStageEl = null;
let remoteVideoEl = null;
let remoteVideoPlaceholderEl = null;
let localPreviewWrapEl = null;
let localVideoEl = null;
let incomingActionsEl = null;
let activeActionsEl = null;
let speakerBtn = null;
let muteBtn = null;
let cameraBtn = null;
let acceptBtn = null;
let declineBtn = null;
let hangupBtn = null;
@@ -28,6 +35,7 @@ let barTextEl = null;
let barActionsEl = null;
let barSpeakerBtn = null;
let barMuteBtn = null;
let barCameraBtn = null;
let barHangupBtn = null;
let unbind = null;
let tickerId = 0;
@@ -60,7 +68,8 @@ function getSnapshotElapsedMs(snapshot) {
function buildBarText(snapshot) {
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
return `Звонок с ${peer} · ${elapsed}`;
const kind = String(snapshot?.titleText || 'Звонок').trim() || 'Звонок';
return `${kind} с ${peer} · ${elapsed}`;
}
function stopEvent(event) {
@@ -87,6 +96,12 @@ function getIconSvg(name) {
if (name === 'speaker-off') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
if (name === 'camera-on') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
}
if (name === 'camera-off') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
return '';
}
@@ -113,6 +128,15 @@ function updateSpeakerButtons(enabled) {
setButtonIcon(barSpeakerBtn, iconName, label);
}
function updateCameraButtons(enabled) {
const iconName = enabled ? 'camera-on' : 'camera-off';
const label = enabled ? 'Выключить камеру' : 'Включить камеру';
setButtonIcon(cameraBtn, iconName, label);
setButtonIcon(barCameraBtn, iconName, label);
if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0';
if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0';
}
async function handleMuteClick(event) {
stopEvent(event);
const nowMuted = event.currentTarget?.dataset?.muted === '1';
@@ -134,6 +158,14 @@ async function handleSpeakerClick(event) {
await toggleSpeakerMode();
}
async function handleCameraClick(event) {
stopEvent(event);
const enabled = event.currentTarget?.dataset?.enabled === '1';
try {
await setCameraEnabled(!enabled);
} catch {}
}
function setMinimized(nextValue) {
minimized = Boolean(nextValue);
render();
@@ -185,12 +217,17 @@ function ensureUi() {
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barMuteBtn.addEventListener('click', handleMuteClick);
barCameraBtn = document.createElement('button');
barCameraBtn.type = 'button';
barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barCameraBtn.addEventListener('click', handleCameraClick);
barHangupBtn = document.createElement('button');
barHangupBtn.type = 'button';
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
barHangupBtn.addEventListener('click', handleEndCallClick);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn);
barEl.append(barTextEl, barActionsEl);
overlayEl = document.createElement('div');
@@ -219,6 +256,31 @@ function ensureUi() {
statusEl = document.createElement('div');
statusEl.className = 'call-overlay-status';
mediaStageEl = document.createElement('div');
mediaStageEl.className = 'call-video-stage';
remoteVideoEl = document.createElement('video');
remoteVideoEl.className = 'call-remote-video';
remoteVideoEl.autoplay = true;
remoteVideoEl.playsInline = true;
remoteVideoEl.muted = true;
remoteVideoPlaceholderEl = document.createElement('div');
remoteVideoPlaceholderEl.className = 'call-video-placeholder';
remoteVideoPlaceholderEl.textContent = 'Собеседник пока без видео';
localPreviewWrapEl = document.createElement('div');
localPreviewWrapEl.className = 'call-local-preview';
localVideoEl = document.createElement('video');
localVideoEl.className = 'call-local-video';
localVideoEl.autoplay = true;
localVideoEl.playsInline = true;
localVideoEl.muted = true;
localPreviewWrapEl.append(localVideoEl);
mediaStageEl.append(remoteVideoEl, remoteVideoPlaceholderEl, localPreviewWrapEl);
incomingActionsEl = document.createElement('div');
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
@@ -235,6 +297,11 @@ function ensureUi() {
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
muteBtn.addEventListener('click', handleMuteClick);
cameraBtn = document.createElement('button');
cameraBtn.type = 'button';
cameraBtn.className = 'call-icon-btn call-icon-btn--secondary';
cameraBtn.addEventListener('click', handleCameraClick);
acceptBtn = document.createElement('button');
acceptBtn.type = 'button';
acceptBtn.className = 'call-accept-btn';
@@ -258,9 +325,9 @@ function ensureUi() {
hangupBtn.addEventListener('click', handleEndCallClick);
incomingActionsEl.append(acceptBtn, declineBtn);
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn);
activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn);
headerEl.append(titleEl, minimizeBtn);
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl);
panelEl.append(headerEl, statusEl, mediaStageEl, incomingActionsEl, activeActionsEl);
overlayEl.append(panelEl);
rootEl.append(barEl, overlayEl);
resolveAppShell().append(rootEl);
@@ -270,13 +337,14 @@ function applyExpandedState(snapshot) {
overlayEl.hidden = false;
barEl.hidden = true;
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
titleEl.textContent = `${snapshot.titleText || 'Звонок'}: ${snapshot.peerLogin || 'пользователь'}`;
statusEl.textContent = snapshot.statusText || '';
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
updateCameraButtons(Boolean(snapshot.cameraEnabled));
minimizeBtn.hidden = incomingStage;
minimizeBtn.disabled = incomingStage;
@@ -290,9 +358,22 @@ function applyExpandedState(snapshot) {
muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute;
cameraBtn.hidden = !snapshot.canToggleCamera;
cameraBtn.disabled = !snapshot.canToggleCamera;
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup;
mediaStageEl.hidden = incomingStage;
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;
remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo;
localPreviewWrapEl.hidden = !snapshot.hasLocalVideo;
}
function applyMinimizedState(snapshot) {
@@ -303,15 +384,18 @@ function applyMinimizedState(snapshot) {
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
updateCameraButtons(Boolean(snapshot.cameraEnabled));
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute;
barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera;
barCameraBtn.disabled = !snapshot.canToggleCamera;
const canEnd = !incomingStage && snapshot.canHangup;
barHangupBtn.hidden = !canEnd;
barHangupBtn.disabled = !canEnd;
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd);
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd);
}
function syncShellState() {
+10 -7
View File
@@ -47,12 +47,12 @@ function buildCallDisplayText(callSummary) {
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
return 'Звонил, но недозвонился';
if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонок отклонён';
return 'Звонок не завершён';
}
export function sanitizeUserDmTextForSend(rawText = '') {
@@ -118,7 +118,10 @@ export function parseDmTechBlocks(rawText = '') {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
callSummary = {
status: 'completed',
durationSec,
};
} else {
callSummary = {
status: 'failed',
+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 = '') {
return String(value || '').trim().replace(/^@+/, '');
@@ -25,21 +25,21 @@ export function extractLoginFromBlockchainName(value = '') {
export function makeProfileRoute(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 = '') {
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') {
const clean = normalizeLogin(login);
if (!clean) return 'channels/feed';
const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/following`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels`;
if (normalizedScope === 'owned') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels/following`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(clean)}/channels`;
}
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
@@ -47,19 +47,33 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!ownerBch || !chName) return '';
if (msgNo) return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
if (msgNo) return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!msgBch || !msgNo) return '';
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
}
export function parseShineRootSegment(segment = '') {
const raw = String(segment || '').trim();
if (!raw.toLowerCase().startsWith(SHINE_ROUTE_PREFIX.toLowerCase())) return '';
return normalizeLogin(decodeRoutePart(raw.slice(SHINE_ROUTE_PREFIX.length)));
export function parseShineRouteParts(segments = []) {
const parts = Array.isArray(segments) ? segments.map((value) => String(value || '').trim()) : [];
if (!parts.length) return null;
if (String(parts[0] || '').toLowerCase() === SHINE_ROUTE_SEGMENT.toLowerCase()) {
const entity = normalizeLogin(decodeRoutePart(parts[1] || ''));
if (!entity) return null;
return { entity, offset: 2, format: 'slash' };
}
const raw = String(parts[0] || '').trim();
const legacyPrefix = `${SHINE_ROUTE_SEGMENT}.`;
if (!raw.toLowerCase().startsWith(legacyPrefix.toLowerCase())) return null;
return {
entity: normalizeLogin(decodeRoutePart(raw.slice(legacyPrefix.length))),
offset: 1,
format: 'dot',
};
}
@@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) {
return parts.join(' :: ');
}
export function isSolanaRpcUnavailableError(error) {
const details = formatSolanaErrorDetails(error).toLowerCase();
return details.includes('service unavailable')
|| details.includes('503')
|| details.includes('failed to fetch')
|| details.includes('fetch failed')
|| details.includes('networkerror')
|| details.includes('network error')
|| details.includes('rpc unavailable')
|| details.includes('rpc error')
|| details.includes('failed to get info about account');
}
export function isUserAlreadyExistsSolanaError(error) {
const details = formatSolanaErrorDetails(error);
return details.includes('UserAlreadyExists')
+423 -18
View File
@@ -1763,7 +1763,6 @@
.bubble-call-card {
display: grid;
gap: 4px;
min-width: min(280px, 72vw);
}
@@ -1771,34 +1770,62 @@
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bubble-call-icon {
display: inline-grid;
place-items: center;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(80, 170, 255, 0.18);
border: 1px solid rgba(123, 191, 255, 0.28);
color: #d9ecff;
font-size: 15px;
flex: 0 0 auto;
}
.bubble-call-icon-phone {
font-size: 28px;
line-height: 1;
transform: translateY(-1px);
}
.bubble-call-icon-arrow {
position: absolute;
right: 4px;
bottom: 3px;
font-size: 10px;
line-height: 1;
font-weight: 700;
}
.bubble-call-icon--incoming .bubble-call-icon-arrow {
color: rgba(167, 225, 255, 0.98);
}
.bubble-call-icon--outgoing .bubble-call-icon-arrow {
color: rgba(255, 214, 132, 0.98);
}
.bubble-call-title {
font-size: 14px;
font-weight: 700;
color: #eef6ff;
line-height: 1.2;
}
.bubble-call-line {
font-size: 12px;
line-height: 1.35;
color: rgba(230, 240, 255, 0.94);
line-height: 1.2;
margin-left: 2px;
}
.bubble-call-line--muted {
color: rgba(188, 207, 238, 0.78);
.bubble-call-line--success {
color: #7ee19e;
}
.bubble-call-line--plain {
color: rgba(230, 240, 255, 0.94);
}
.chat-input {
@@ -1974,6 +2001,56 @@
font-size: 14px;
}
.call-video-stage {
position: relative;
width: 100%;
min-height: 220px;
border-radius: 16px;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(41, 72, 118, 0.48), transparent 58%),
linear-gradient(180deg, rgba(7, 14, 27, 0.98), rgba(3, 7, 14, 1));
border: 1px solid rgba(118, 154, 228, 0.2);
}
.call-remote-video,
.call-local-video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: #02050a;
}
.call-remote-video[hidden],
.call-local-preview[hidden] {
display: none !important;
}
.call-video-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
color: rgba(223, 234, 255, 0.72);
font-size: 15px;
text-align: center;
}
.call-local-preview {
position: absolute;
right: 12px;
bottom: 12px;
width: min(32vw, 128px);
aspect-ratio: 3 / 4;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(176, 205, 255, 0.38);
background: rgba(7, 12, 22, 0.94);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
}
.call-overlay-controls {
display: flex;
flex-wrap: wrap;
@@ -2278,12 +2355,21 @@ textarea.input {
touch-action: pan-y;
}
.message-attachment-carousel-viewport.has-landscape-media {
align-items: start;
min-height: 0;
}
.message-attachment-slide {
align-items: stretch;
display: grid;
min-height: 12rem;
}
.message-attachment-slide.has-landscape-media {
min-height: 0;
}
.message-attachment-arrow {
align-items: center;
background: rgba(15, 23, 42, 0.62);
@@ -2336,6 +2422,10 @@ textarea.input {
position: relative;
}
.message-attachment-media-frame.is-landscape {
min-height: 0;
}
.message-attachment-media {
display: block;
height: min(52vh, 20rem);
@@ -2344,6 +2434,10 @@ textarea.input {
width: 100%;
}
.message-attachment-media.is-landscape {
height: auto;
}
.message-attachment-play {
align-items: center;
background: rgba(15, 23, 42, 0.72);
@@ -3912,6 +4006,12 @@ textarea.input {
gap: 10px;
}
.channels-screen--channel .channel-feed {
gap: 2px;
margin-left: -7px;
margin-right: -7px;
}
.channels-screen .channel-message-card {
gap: 14px;
padding: 20px;
@@ -3923,6 +4023,18 @@ textarea.input {
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
}
.channels-screen--channel .channel-message-card {
margin-bottom: 6px;
}
.channels-screen--channel .channel-message-card.is-diary-entry {
width: min(100%, 28rem);
max-width: min(84vw, 28rem);
padding: 16px 16px 15px;
justify-self: center;
align-self: center;
}
.channel-message-top {
display: flex;
align-items: center;
@@ -3935,7 +4047,9 @@ textarea.input {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
@@ -3976,11 +4090,80 @@ textarea.input {
min-width: 0;
}
.channel-message-head-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.channel-message-title {
font-size: 15px;
color: #f5f8ff;
}
.channel-message-kind-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 1.2;
}
.channel-message-kind-badge--rating {
color: #ffe8b0;
background: rgba(124, 92, 28, 0.36);
border: 1px solid rgba(255, 214, 117, 0.28);
}
.channel-message-kind-badge--status {
color: #bfe9d1;
background: rgba(38, 92, 62, 0.32);
border: 1px solid rgba(137, 223, 176, 0.26);
}
.channel-message-target-preview {
display: grid;
gap: 4px;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid rgba(154, 178, 224, 0.16);
background: rgba(13, 22, 39, 0.56);
}
.channel-message-target-preview strong {
color: #f0d99c;
font-size: 12px;
}
.channel-message-target-preview span {
color: rgba(188, 208, 244, 0.82);
font-size: 12px;
}
.channel-message-target-preview p {
margin: 0;
color: #d9e6ff;
font-size: 13px;
line-height: 1.35;
}
.channels-screen .channel-message-card.is-rating,
.thread-node-card.is-rating {
border-color: rgba(255, 214, 117, 0.34);
background:
linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)),
rgba(20, 25, 35, 0.55);
box-shadow: 0 0 38px rgba(181, 136, 42, 0.14);
}
.channels-screen .channel-message-card.is-focus-flash {
border-color: rgba(255, 214, 117, 0.5);
box-shadow: 0 0 0 1px rgba(255, 214, 117, 0.28), 0 0 34px rgba(255, 214, 117, 0.16);
}
.channel-message-body {
color: #ffffff;
line-height: 1.5;
@@ -4010,19 +4193,41 @@ textarea.input {
.channel-system-event-card {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 10px;
padding: 9px 11px;
width: fit-content;
max-width: min(88%, 460px);
margin: 0 auto 4px;
padding: 8px 16px;
border-color: rgba(255, 210, 130, 0.22);
background: rgba(89, 67, 31, 0.22);
border-radius: 999px;
background: rgba(89, 67, 31, 0.26);
color: rgba(255, 235, 188, 0.92);
cursor: pointer;
text-align: center;
}
.channel-system-event-card small {
flex: 0 0 auto;
.channel-system-event-card__label {
display: block;
font-size: 13px;
line-height: 1.2;
}
.channel-meta-details-grid {
display: grid;
grid-template-columns: minmax(96px, 120px) 1fr;
gap: 10px 14px;
align-items: start;
color: rgba(255, 255, 255, 0.92);
}
.channel-meta-details-grid > span:nth-child(odd) {
color: rgba(255, 255, 255, 0.48);
font-size: 11px;
}
.channel-meta-details-grid code {
color: rgba(255, 214, 117, 0.92);
word-break: break-word;
}
.channel-profile-card {
@@ -4864,9 +5069,62 @@ textarea.input {
}
.author-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
width: 100%;
}
.author-line-main {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.channel-message-type-chip,
.channel-message-type-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 4px 9px;
border-radius: 9px;
border: 1px solid rgba(224, 190, 117, 0.38);
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
color: #f1d99c;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.channel-message-type-button {
min-width: 82px;
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.channel-message-type-button.is-static,
.channel-message-type-button:disabled {
opacity: 1;
cursor: default;
}
.channel-message-tools {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.channel-message-type-select {
min-width: 0;
min-height: 46px;
}
.author-line-login {
@@ -5282,6 +5540,58 @@ textarea.input {
color: #d9e6ff;
}
.channels-screen--channel .page-header .channel-header-entrypoint-btn {
border: 1px solid rgba(224, 190, 117, 0.38);
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
color: #f1d99c;
border-radius: 10px;
min-height: 36px;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
transform: translateY(-40%);
}
.entrypoint-history-list {
display: grid;
gap: 10px;
max-height: min(58vh, 30rem);
overflow: auto;
}
.entrypoint-history-item {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(17, 24, 39, 0.72);
color: #eef3ff;
display: grid;
gap: 4px;
padding: 12px 13px;
text-align: left;
}
.entrypoint-history-item strong {
color: #f1d99c;
font-size: 13px;
}
.entrypoint-history-item span {
color: rgba(188, 208, 244, 0.74);
font-size: 12px;
}
.entrypoint-history-item p {
margin: 0;
color: #f5f8ff;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.channel-head-actions .secondary-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
@@ -5304,6 +5614,7 @@ textarea.input {
}
#about-channel-modal.modal,
#edit-channel-modal.modal,
#reply-modal.modal,
#channel-message-modal.modal,
#channel-edit-description-modal.modal,
@@ -5315,6 +5626,7 @@ textarea.input {
}
#about-channel-modal .modal-card,
#edit-channel-modal .modal-card,
#reply-modal .modal-card,
#channel-message-modal .modal-card,
#channel-edit-description-modal .modal-card,
@@ -5329,6 +5641,7 @@ textarea.input {
}
#about-channel-modal .modal-title,
#edit-channel-modal .modal-title,
#reply-modal .modal-title,
#channel-message-modal .modal-title,
#channel-edit-description-modal .modal-title,
@@ -5341,6 +5654,7 @@ textarea.input {
}
#about-channel-modal .input,
#edit-channel-modal .input,
#reply-modal .input,
#channel-message-modal .input,
#channel-edit-description-modal .input,
@@ -5363,6 +5677,7 @@ textarea.input {
}
#about-channel-modal .secondary-btn,
#edit-channel-modal .secondary-btn,
#reply-modal .secondary-btn,
#channel-message-modal .secondary-btn,
#channel-edit-description-modal .secondary-btn,
@@ -5376,6 +5691,7 @@ textarea.input {
}
#about-channel-modal .primary-btn,
#edit-channel-modal .primary-btn,
#reply-modal .primary-btn,
#channel-message-modal .primary-btn,
#channel-edit-description-modal .primary-btn,
@@ -6557,6 +6873,95 @@ html, body { overflow-x: hidden; }
box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1);
}
.channel-create-avatar-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.channel-create-avatar-side {
min-width: 0;
display: grid;
gap: 10px;
align-content: start;
}
.channel-create-avatar-status-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.channel-create-avatar-status {
min-width: 0;
}
.channel-avatar-status-text {
margin: 0;
color: rgba(255, 255, 255, 0.96);
font-weight: 500;
font-size: 12px;
line-height: 1.35;
}
.channel-avatar-status-line {
margin: 2px 0 0;
color: rgba(255, 255, 255, 0.96);
font-size: 12px;
line-height: 1.35;
}
.channel-avatar-txid {
margin: 0;
word-break: break-all;
color: rgba(255, 255, 255, 0.96);
font-size: 11px;
}
.channel-avatar-status-pending {
color: rgba(255, 255, 255, 0.82);
}
.channel-create-login-hint {
font-size: 11px;
line-height: 1.35;
}
.channel-link-preview {
min-height: 18px;
}
.channel-avatar-remove-btn {
width: 26px;
min-width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 107, 107, 0.75);
border-radius: 8px;
background: rgba(150, 18, 18, 0.3);
color: #ff8b8b;
font-weight: 700;
line-height: 1;
}
.channel-avatar-remove-btn:hover,
.channel-avatar-remove-btn:focus-visible {
background: rgba(180, 22, 22, 0.42);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.16);
}
.channel-avatar-remove-btn[hidden] {
display: none;
}
@media (max-width: 420px) {
.channel-create-avatar-head {
grid-template-columns: 1fr;
}
}
/* ===== Final microinteractions: breathe cards + static energy buttons ===== */
@keyframes breatheCard {
0%, 100% { transform: translateY(0px); }
@@ -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` отложен в отдельный черновик и в первую итерацию не входит.