SHA256
Compare commits
30
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
4b0a934e51 | ||
|
|
ee5d86003b | ||
|
|
bc11b60147 | ||
|
|
801a4697ea | ||
|
|
628a970e6f | ||
|
|
566ea942dc | ||
|
|
e047c2d54e | ||
|
|
3fdd3d22e2 | ||
|
|
08e0fa9206 | ||
|
|
ed9e6ce676 | ||
|
|
a7bf07130e | ||
|
|
7c31662698 | ||
|
|
15df06a0bc | ||
|
|
47b5f8db7b | ||
|
|
0e763987aa | ||
|
|
37e8ea27d0 | ||
|
|
67d63256c2 | ||
|
|
ee185cf20a | ||
|
|
3552e05e4c | ||
|
|
735bb55b78 | ||
|
|
60f8a50a97 | ||
|
|
ffd01814b0 | ||
|
|
c511910b8f | ||
|
|
5c38f8f0d8 | ||
|
|
d512442325 | ||
|
|
43f54c90d2 | ||
|
|
57a99b478a | ||
|
|
3f402fbde7 | ||
|
|
98140c1f71 | ||
|
|
f00de85e38 |
@@ -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;
|
||||
}
|
||||
|
||||
+166
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -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) {
|
||||
|
||||
+157
@@ -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()) {
|
||||
|
||||
@@ -53,12 +53,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -92,12 +96,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -162,12 +162,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -201,12 +205,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
+4
@@ -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),
|
||||
|
||||
+9
-1
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
|
||||
String serverAddress = safe(serverProfile.serverAddress());
|
||||
if (serverAddress.isBlank()) continue;
|
||||
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
||||
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
|
||||
}
|
||||
return new ArrayList<>(routes.values());
|
||||
}
|
||||
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
accessServers.add(normalizeLogin(accessServerLogin));
|
||||
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
||||
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
||||
continue;
|
||||
}
|
||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalizedAccessServerLogin);
|
||||
}
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
|
||||
+88
@@ -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("""
|
||||
|
||||
+69
-9
@@ -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;
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+22
-11
@@ -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();
|
||||
|
||||
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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;
|
||||
|
||||
+24
@@ -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; }
|
||||
|
||||
|
||||
+18
@@ -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; }
|
||||
}
|
||||
+22
-3
@@ -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)) {
|
||||
|
||||
+204
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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; }
|
||||
}
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-12
@@ -1057,12 +1057,16 @@ public final class PostgresStorageRepository
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -1100,12 +1104,16 @@ public final class PostgresStorageRepository
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
client.version=1.5.1
|
||||
server.version=1.4.5
|
||||
client.version=1.5.35
|
||||
server.version=1.4.11
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 использует тот же формат полного снимка профиля:
|
||||
|
||||
|
||||
@@ -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` у пользователя сейчас нет активных онлайн-сессий.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
||||
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
|
||||
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
- `12_REACTION_Blocks.md` — реакции (`type=2`).
|
||||
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
|
||||
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
|
||||
- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`).
|
||||
|
||||
## Быстрая карта типов
|
||||
|
||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||
- `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED.
|
||||
|
||||
## Примечание
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
||||
|
||||
- `msg_type=1`
|
||||
- `subType=70`
|
||||
- `subType=90`
|
||||
- `TEXT_CHANNEL_META`
|
||||
|
||||
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||
|
||||
@@ -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` нет.
|
||||
- Если нужно изменить смысл статуса, пишется новое статусное событие.
|
||||
@@ -5,7 +5,7 @@
|
||||
## Блок
|
||||
|
||||
- `msg_type = 1`
|
||||
- `subType = 70`
|
||||
- `subType = 90`
|
||||
- `msgVersion = 1`
|
||||
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
Журнал изменений документации.
|
||||
|
||||
## Смежная документация
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
+6
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
||||
@@ -37,7 +40,9 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
<div class="initial-splash__brand">Сияние</div>
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<div id="topbar-slot" class="topbar-slot" hidden></div>
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="composer-slot" class="composer-slot" hidden></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
+135
-4
@@ -10,6 +10,7 @@ import {
|
||||
handleIncomingCallInvite,
|
||||
handleIncomingCallPush,
|
||||
handleIncomingCallSignal,
|
||||
handleIncomingCallSignalViaSendSignal,
|
||||
handleStopCallPush,
|
||||
setCallDebugReporter,
|
||||
startDebugConnectionAsInitiator,
|
||||
@@ -150,6 +151,8 @@ const routes = {
|
||||
};
|
||||
|
||||
const screenEl = document.getElementById('app-screen');
|
||||
const topbarEl = document.getElementById('topbar-slot');
|
||||
const composerEl = document.getElementById('composer-slot');
|
||||
const toolbarEl = document.getElementById('toolbar-slot');
|
||||
const appShellEl = document.querySelector('.app-shell');
|
||||
const initialSplashEl = document.getElementById('initial-splash');
|
||||
@@ -179,6 +182,8 @@ let uiVersionPeriodicIntervalId = null;
|
||||
let hiddenDmAudioContext = null;
|
||||
let hiddenDmAudioUnlocked = false;
|
||||
let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
@@ -202,6 +207,90 @@ initPwaInstallPromptHandling();
|
||||
initCallUiOverlay();
|
||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||
|
||||
function setShellMetricVar(name, valuePx) {
|
||||
if (!appShellEl) return;
|
||||
appShellEl.style.setProperty(name, `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
|
||||
}
|
||||
|
||||
function setKeyboardOffsetPx(valuePx = 0) {
|
||||
setShellMetricVar('--keyboard-offset', valuePx);
|
||||
}
|
||||
|
||||
function attachSlotHeightObserver(slotEl, cssVarName) {
|
||||
if (!slotEl || typeof ResizeObserver !== 'function') return null;
|
||||
const sync = () => {
|
||||
const visible = !slotEl.hidden && slotEl.childElementCount > 0;
|
||||
setShellMetricVar(cssVarName, visible ? slotEl.offsetHeight : 0);
|
||||
};
|
||||
const observer = new ResizeObserver(sync);
|
||||
observer.observe(slotEl);
|
||||
sync();
|
||||
return { observer, sync };
|
||||
}
|
||||
|
||||
const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height');
|
||||
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
|
||||
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
slotEl.hidden = true;
|
||||
setShellMetricVar(cssVarName, 0);
|
||||
}
|
||||
|
||||
function mountSlot(slotEl, cssVarName, node) {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
if (node instanceof Node) {
|
||||
slotEl.append(node);
|
||||
slotEl.hidden = false;
|
||||
} else {
|
||||
slotEl.hidden = true;
|
||||
}
|
||||
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
|
||||
}
|
||||
|
||||
function createChromeController(showAppChrome) {
|
||||
let topbarNode = null;
|
||||
let composerNode = null;
|
||||
let disposed = false;
|
||||
|
||||
const apply = () => {
|
||||
if (disposed) return;
|
||||
if (!showAppChrome) {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
return;
|
||||
}
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode);
|
||||
mountSlot(composerEl, '--composer-height', composerNode);
|
||||
topbarHeightObserver?.sync?.();
|
||||
composerHeightObserver?.sync?.();
|
||||
};
|
||||
|
||||
return {
|
||||
setTopbar(node = null) {
|
||||
topbarNode = node instanceof Node ? node : null;
|
||||
apply();
|
||||
},
|
||||
setComposer(node = null) {
|
||||
composerNode = node instanceof Node ? node : null;
|
||||
apply();
|
||||
},
|
||||
clear() {
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
apply();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -257,6 +346,23 @@ async function playDmSignal({ extended = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLockPortraitOrientation() {
|
||||
if (orientationLockInFlight) return false;
|
||||
|
||||
const orientationApi = window.screen?.orientation;
|
||||
if (!orientationApi?.lock) return false;
|
||||
|
||||
orientationLockInFlight = true;
|
||||
try {
|
||||
await orientationApi.lock('portrait');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
orientationLockInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyHiddenIncomingMessage(fromLogin, text) {
|
||||
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
|
||||
const title = `Сообщение от ${fromLogin}`;
|
||||
@@ -889,7 +995,14 @@ function renderPageFailureFallback(pageId, error) {
|
||||
screenEl.append(wrap);
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
toolbarEl.innerHTML = '';
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
@@ -913,10 +1026,17 @@ function renderApp() {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
screenEl.innerHTML = '';
|
||||
const screen = page.render({ route, navigate });
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
const chrome = createChromeController(showAppChrome);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
}
|
||||
@@ -924,7 +1044,6 @@ function renderApp() {
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
|
||||
@@ -932,6 +1051,7 @@ function renderApp() {
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
@@ -949,6 +1069,7 @@ function refreshToolbarOnly() {
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
@@ -1027,6 +1148,7 @@ async function ensureSessionRuntimeStarted() {
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
if (state.session.isLocalDemo) {
|
||||
setConnectionStatus('connected');
|
||||
@@ -1210,13 +1332,14 @@ async function init() {
|
||||
if (added && isIncomingForCurrent && !payload.backlog) {
|
||||
const parsedText = parseDmTechBlocks(text);
|
||||
const displayText = String(parsedText.displayText || '');
|
||||
const suppressHiddenNotification = parsedText.callSummary?.status === 'completed';
|
||||
if (document.visibilityState === 'visible') {
|
||||
void playDmSignal({ extended: false });
|
||||
} else if (Notification.permission === 'granted') {
|
||||
} else if (Notification.permission === 'granted' && !suppressHiddenNotification) {
|
||||
try {
|
||||
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
|
||||
} catch {}
|
||||
} else {
|
||||
} else if (!suppressHiddenNotification) {
|
||||
void playDmSignal({ extended: true });
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1436,10 @@ async function init() {
|
||||
try { await handleIncomingCallSignal(evt); } catch {}
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingSignal', async (evt) => {
|
||||
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
|
||||
});
|
||||
|
||||
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
|
||||
try {
|
||||
const p = evt?.payload || {};
|
||||
@@ -1405,8 +1532,12 @@ async function init() {
|
||||
}, { passive: true });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void tryLockPortraitOrientation();
|
||||
void checkConnectionHealth();
|
||||
});
|
||||
window.addEventListener('orientationchange', () => {
|
||||
void tryLockPortraitOrientation();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -286,7 +286,7 @@ export function openArweaveAttachmentManager({
|
||||
historyOnly = false,
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'arweave',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
@@ -303,7 +303,7 @@ export function openArweaveAttachmentManager({
|
||||
let turboKeyChoices = [];
|
||||
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
|
||||
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
|
||||
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave';
|
||||
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'arweave' ? 'arweave' : 'turbo';
|
||||
let selectedFile = null;
|
||||
let selectedSha256 = '';
|
||||
let selectedPreviewEnabled = false;
|
||||
@@ -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));
|
||||
@@ -589,17 +592,18 @@ export function openArweaveAttachmentManager({
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<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
|
||||
? `
|
||||
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Файлы до 100 KB пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
`
|
||||
: `
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
@@ -860,12 +864,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 +915,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 +946,7 @@ export function openArweaveAttachmentManager({
|
||||
ar: txId,
|
||||
preview,
|
||||
uploadedAtMs: Date.now(),
|
||||
purpose: isAvatarMode ? 'avatar' : 'attachment',
|
||||
});
|
||||
metaEl.innerHTML = `
|
||||
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
|
||||
@@ -950,7 +957,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) {
|
||||
|
||||
@@ -78,7 +78,6 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
<span class="toolbar-label-wrap">
|
||||
<span>${item.label}</span>
|
||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
<span class="toolbar-connection-text">connected</span>
|
||||
<span class="toolbar-connection-dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-
|
||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
const MAX_ACCESS_SERVERS = 2;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -39,8 +40,9 @@ function uniqueLogins(values) {
|
||||
if (!login || seen.has(login)) return;
|
||||
seen.add(login);
|
||||
result.push(login);
|
||||
if (result.length >= MAX_ACCESS_SERVERS) return;
|
||||
});
|
||||
return result;
|
||||
return result.slice(0, MAX_ACCESS_SERVERS);
|
||||
}
|
||||
|
||||
function shortenSignature(value) {
|
||||
@@ -309,7 +311,7 @@ export function render({ navigate }) {
|
||||
addTitle.textContent = 'Добавить сервер доступа';
|
||||
const addHint = document.createElement('p');
|
||||
addHint.className = 'meta-muted';
|
||||
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'input';
|
||||
addInput.type = 'text';
|
||||
@@ -329,7 +331,9 @@ export function render({ navigate }) {
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
addButton.disabled = operationBusy
|
||||
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
const setOperationBusy = (busy) => {
|
||||
@@ -348,7 +352,9 @@ export function render({ navigate }) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
|
||||
: 'Для изменения списка понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -457,12 +463,17 @@ export function render({ navigate }) {
|
||||
}
|
||||
currentAccessServers = rows;
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? `Найдено серверов доступа: ${rows.length}`
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
if (rows.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
|
||||
}
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
||||
}
|
||||
};
|
||||
@@ -654,6 +665,10 @@ export function render({ navigate }) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
|
||||
@@ -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"> </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 = ' ';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function render({ navigate }) {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n'
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -1008,7 +1114,7 @@ function renderSkeleton(screen) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
@@ -1028,6 +1134,7 @@ export function render({ navigate, route }) {
|
||||
threadHeaderButton.classList.add('channel-header-route-btn');
|
||||
threadHeaderButton.disabled = true;
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
@@ -1104,6 +1211,15 @@ export function render({ navigate, route }) {
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onRating: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
@@ -1194,7 +1310,7 @@ export function render({ navigate, route }) {
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(header, statusBox);
|
||||
screen.append(statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
@@ -1326,12 +1442,9 @@ export function render({ navigate, route }) {
|
||||
if (ancestors.length) {
|
||||
ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'История выше (на что это ответ)';
|
||||
ancestorsWrap.append(title);
|
||||
ancestors.forEach((node, index) => {
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
|
||||
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1350,7 +1463,7 @@ export function render({ navigate, route }) {
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы';
|
||||
descendantsTitle.textContent = 'Ответы и оценки';
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
@@ -1358,7 +1471,7 @@ export function render({ navigate, route }) {
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов пока нет.';
|
||||
empty.textContent = 'Ответов и оценок пока нет.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,28 @@ import {
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.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 +305,82 @@ function resolveMessageTimestampMs(message) {
|
||||
);
|
||||
}
|
||||
|
||||
function getChannelMessageTypeMeta(msgSubType) {
|
||||
switch (Number(msgSubType || 0)) {
|
||||
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||
return { label: 'Упражнение', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||
return { label: 'Услуга', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_COURSE:
|
||||
return { label: 'Курс', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||
return { label: 'Оглавление' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isEntrypointSubType(msgSubType) {
|
||||
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT;
|
||||
}
|
||||
|
||||
function isDiarySelector(selector) {
|
||||
return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME;
|
||||
}
|
||||
|
||||
function isStatusActionSubType(msgSubType) {
|
||||
return new Set([
|
||||
MSG_SUBTYPE_STATUS_DONE_ONCE,
|
||||
MSG_SUBTYPE_STATUS_LEARNED,
|
||||
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
|
||||
MSG_SUBTYPE_STATUS_CONFIRMED,
|
||||
MSG_SUBTYPE_STATUS_INTERESTED,
|
||||
MSG_SUBTYPE_STATUS_STARTED,
|
||||
MSG_SUBTYPE_STATUS_IN_STUDY,
|
||||
MSG_SUBTYPE_STATUS_ABANDONED,
|
||||
MSG_SUBTYPE_STATUS_COMPLETED,
|
||||
]).has(Number(msgSubType || 0));
|
||||
}
|
||||
|
||||
function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) {
|
||||
const status = Number(statusSubType || 0);
|
||||
const target = Number(targetMsgSubType || 0);
|
||||
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' };
|
||||
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' };
|
||||
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' };
|
||||
if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' };
|
||||
if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' };
|
||||
if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' };
|
||||
if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' };
|
||||
if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' };
|
||||
if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' };
|
||||
return { label: 'Действие' };
|
||||
}
|
||||
|
||||
function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
switch (Number(targetMsgSubType || 0)) {
|
||||
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' },
|
||||
];
|
||||
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' },
|
||||
];
|
||||
case MSG_SUBTYPE_TEXT_COURSE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
@@ -303,7 +397,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 +419,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 +448,7 @@ function openAboutChannelModal(channel, options = {}) {
|
||||
channel,
|
||||
canEdit: options.canEdit === true,
|
||||
onEdit: options.onEdit,
|
||||
changedAtMs: Number(channel?.metaUpdatedAtMs || 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -541,13 +653,22 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
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 +705,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -597,7 +718,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -626,6 +747,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 +1015,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 +1035,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 +1046,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 +1060,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 +1070,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 +1201,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 +1218,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 +1237,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 +1270,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 +1551,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 +1568,7 @@ function renderChannelMetaEventCard(event) {
|
||||
avaSize: event.avaSize,
|
||||
ownerName: event.ownerName,
|
||||
},
|
||||
changedAtMs: event.timestampMs,
|
||||
});
|
||||
});
|
||||
return card;
|
||||
@@ -1209,6 +1579,9 @@ function renderPostCard(post, {
|
||||
selector,
|
||||
onToggleLike,
|
||||
onReply,
|
||||
onRating,
|
||||
onStatusAction,
|
||||
onOpenEntrypointMenu,
|
||||
onRepost,
|
||||
onShare,
|
||||
onEdit,
|
||||
@@ -1217,6 +1590,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 +1601,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 +1615,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 +1639,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 +1691,39 @@ 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 targetParsed = parseDmTechBlocks(String(post.targetText || '').trim());
|
||||
const targetText = String(targetParsed.displayText || targetParsed.visibleText || '').trim();
|
||||
const targetAuthor = String(post.targetAuthorLogin || '').trim();
|
||||
targetPreview.innerHTML = `
|
||||
<strong>${escapeHtml(targetType)}</strong>
|
||||
<span>${escapeHtml(targetAuthor || 'автор')}</span>
|
||||
<p>${escapeHtml(targetText || 'Без текста')}</p>
|
||||
`;
|
||||
card.append(targetPreview);
|
||||
}
|
||||
}
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedBody.text;
|
||||
@@ -1357,9 +1787,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 +1825,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 +1954,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 +1970,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 +1986,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 +1996,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
|
||||
};
|
||||
@@ -1555,7 +2010,7 @@ function renderSkeleton(screen) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const channelId = route.params.channelId || '';
|
||||
const routeSelector = buildSelectorFromRoute(route, channelId);
|
||||
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
|
||||
@@ -1582,13 +2037,21 @@ 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;
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const rerender = () => {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
@@ -1651,6 +2114,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 +2227,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 +2238,7 @@ export function render({ navigate, route }) {
|
||||
storagePwd,
|
||||
channel: activeSelector,
|
||||
text: bodyText,
|
||||
msgSubType,
|
||||
});
|
||||
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
@@ -1744,7 +2249,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 +2258,8 @@ export function render({ navigate, route }) {
|
||||
storagePwd,
|
||||
message: messageRef,
|
||||
text,
|
||||
isChannelPost: true,
|
||||
channel: activeSelector,
|
||||
isChannelPost: !isDiaryEdit,
|
||||
channel: isDiaryEdit ? null : activeSelector,
|
||||
});
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
@@ -1779,7 +2285,6 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
@@ -1791,13 +2296,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 +2322,26 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
};
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||
channelEntrypointButton.disabled = !canShowEntrypointButton;
|
||||
channelEntrypointButton.onclick = () => {
|
||||
const latestEntrypoint = entrypointPosts[0];
|
||||
if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) {
|
||||
showToast('Не удалось найти актуальное оглавление');
|
||||
}
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
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 +2365,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);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
@@ -21,10 +22,14 @@ 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);
|
||||
return String(parsed.text || '').trim() || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|
||||
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
}
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
@@ -698,11 +703,64 @@ function pullCreateSuccessFlash() {
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiFeed(feed, notificationsState) {
|
||||
function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) {
|
||||
const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
|
||||
if (!messages.length) return null;
|
||||
|
||||
const ownerBlockchainName = String(
|
||||
diaryPayload?.channel?.ownerBlockchainName
|
||||
|| ownRows[0]?.channel?.ownerBlockchainName
|
||||
|| ''
|
||||
).trim();
|
||||
if (!ownerBlockchainName) return null;
|
||||
|
||||
const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim();
|
||||
const lastMessage = messages[messages.length - 1] || null;
|
||||
const rowId = 'own-diary';
|
||||
index[rowId] = { diary: true, payload: diaryPayload };
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
route: makeShineChannelRoute({
|
||||
ownerLogin,
|
||||
ownerBlockchainName,
|
||||
channelName: DIARY_CHANNEL_NAME,
|
||||
}),
|
||||
ownerName: ownerLogin || 'я',
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: 0,
|
||||
channelRootBlockHash: '0',
|
||||
avatar: 'Д',
|
||||
avaAr: '',
|
||||
title: DIARY_DISPLAY_NAME,
|
||||
technicalLabel: 'Виртуальная лента Дневника',
|
||||
channelName: DIARY_CHANNEL_NAME,
|
||||
displayTitle: DIARY_DISPLAY_NAME,
|
||||
channelDescription: 'История действий по упражнениям, услугам и курсам',
|
||||
channelTypeCode: 900,
|
||||
channelTypeVersion: 1,
|
||||
messagePreview: String(lastMessage?.text || '').trim()
|
||||
? cleanChannelMessagePreview(lastMessage?.text)
|
||||
: 'Новая запись в Дневнике',
|
||||
messagesCount: messages.length,
|
||||
unreadCount: 0,
|
||||
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
|
||||
isOwnChannel: true,
|
||||
isSubscribed: false,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
pending: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mapApiFeed(feed, notificationsState, diaryPayload = null) {
|
||||
const index = {};
|
||||
const 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));
|
||||
@@ -1092,11 +1150,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
||||
const baseClass = `primary-btn channels-bottom-action${isTabEmpty ? ' is-empty-lift' : ''}`;
|
||||
button.textContent = 'Поиск каналов';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
@@ -1126,7 +1185,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
const groups = mapApiFeed(feed, listState.notificationsState);
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
} catch {
|
||||
diaryPayload = null;
|
||||
}
|
||||
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
|
||||
|
||||
listState.channels = toListModel(groups);
|
||||
setChannelsFeed(feed, groups.index);
|
||||
@@ -1151,7 +1216,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
@@ -1245,26 +1310,17 @@ export function render({ navigate, route }) {
|
||||
createInMyBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
navigate,
|
||||
isTabEmpty,
|
||||
});
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
screen.append(topBarEl, contentEl, bottomCta);
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
navigate,
|
||||
isTabEmpty: true,
|
||||
});
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
|
||||
@@ -245,6 +245,8 @@ function openChatActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
onCall,
|
||||
onVideoCall,
|
||||
onInstantVideoCall,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
@@ -255,7 +257,9 @@ function openChatActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||
</div>
|
||||
@@ -303,6 +307,14 @@ function openChatActionsMenu({
|
||||
close();
|
||||
if (typeof onCall === 'function') await onCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
@@ -440,12 +452,12 @@ function buildCallStatusText(callSummary) {
|
||||
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
|
||||
}
|
||||
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
||||
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
|
||||
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
|
||||
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
|
||||
if (reason === 'busy') return 'Не дозвонился: абонент занят';
|
||||
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
|
||||
return 'Не дозвонился';
|
||||
if (reason === 'offline') return 'Абонент не в сети';
|
||||
if (reason === 'no_answer') return 'Нет ответа';
|
||||
if (reason === 'connect_failed') return 'Не удалось установить соединение';
|
||||
if (reason === 'busy') return 'Абонент занят';
|
||||
if (reason === 'declined') return 'Звонок отклонён';
|
||||
return 'Звонок не завершён';
|
||||
}
|
||||
|
||||
function resolveEffectiveReadState(messages, msg) {
|
||||
@@ -594,20 +606,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
|
||||
|
||||
const callIcon = document.createElement('span');
|
||||
callIcon.className = 'bubble-call-icon';
|
||||
callIcon.textContent = '☎';
|
||||
callIcon.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming');
|
||||
|
||||
const callIconPhone = document.createElement('span');
|
||||
callIconPhone.className = 'bubble-call-icon-phone';
|
||||
callIconPhone.textContent = '✆';
|
||||
|
||||
const callIconArrow = document.createElement('span');
|
||||
callIconArrow.className = 'bubble-call-icon-arrow';
|
||||
callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓';
|
||||
|
||||
callIcon.append(callIconPhone, callIconArrow);
|
||||
|
||||
const callTitle = document.createElement('span');
|
||||
callTitle.className = 'bubble-call-title';
|
||||
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
|
||||
callTitle.textContent = 'Звонок';
|
||||
|
||||
callHead.append(callIcon, callTitle);
|
||||
|
||||
const callMetaLine = document.createElement('div');
|
||||
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
|
||||
const callMetaLine = document.createElement('span');
|
||||
callMetaLine.className = 'bubble-call-line';
|
||||
callMetaLine.classList.add(
|
||||
parsedText.callSummary.status === 'completed'
|
||||
? 'bubble-call-line--success'
|
||||
: 'bubble-call-line--plain',
|
||||
);
|
||||
const callStatus = buildCallStatusText(parsedText.callSummary);
|
||||
callMetaLine.textContent = callStatus;
|
||||
|
||||
callCard.append(callHead, callMetaLine);
|
||||
callHead.append(callIcon, callTitle, callMetaLine);
|
||||
callCard.append(callHead);
|
||||
bubble.append(callCard);
|
||||
}
|
||||
|
||||
@@ -757,9 +783,10 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
|
||||
function setChatKeyboardOpen(isOpen) {
|
||||
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
|
||||
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const routeChatId = route.params.chatId || 'u1';
|
||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
||||
@@ -789,9 +816,9 @@ export function render({ navigate, route }) {
|
||||
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
const handleStartCall = async (mode = 'audio') => {
|
||||
try {
|
||||
await startOutgoingCall(chatId);
|
||||
await startOutgoingCall(chatId, { mode });
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
@@ -845,7 +872,7 @@ export function render({ navigate, route }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
screen.append(
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
@@ -855,10 +882,10 @@ export function render({ navigate, route }) {
|
||||
ariaLabel: 'Позвонить',
|
||||
className: 'chat-header-icon-btn chat-header-call-btn',
|
||||
iconNode: createHeaderPhoneHandsetIcon(),
|
||||
onClick: handleStartCall,
|
||||
onClick: () => handleStartCall('audio'),
|
||||
},
|
||||
{
|
||||
label: '⋯',
|
||||
label: '⋮',
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
@@ -866,7 +893,9 @@ export function render({ navigate, route }) {
|
||||
openChatActionsMenu({
|
||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
onCall: handleStartCall,
|
||||
onCall: () => handleStartCall('audio'),
|
||||
onVideoCall: () => handleStartCall('video'),
|
||||
onInstantVideoCall: () => handleStartCall('instant_video'),
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
@@ -913,7 +942,7 @@ export function render({ navigate, route }) {
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isKnownContact) {
|
||||
@@ -963,7 +992,7 @@ export function render({ navigate, route }) {
|
||||
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
|
||||
</div>
|
||||
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
|
||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
|
||||
<div class="dm-actions-col">
|
||||
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
|
||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||
@@ -982,11 +1011,24 @@ export function render({ navigate, route }) {
|
||||
let emojiPickerOpen = false;
|
||||
let emojiSelection = null;
|
||||
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
const appShell = document.querySelector('.app-shell');
|
||||
|
||||
const setKeyboardInset = (valuePx = 0) => {
|
||||
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
|
||||
};
|
||||
|
||||
const syncKeyboardUi = () => {
|
||||
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
const viewport = window.visualViewport || null;
|
||||
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
|
||||
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
|
||||
const keyboardInset = viewportShrunk
|
||||
? Math.max(0, baseViewportHeight - viewportHeight)
|
||||
: 0;
|
||||
setKeyboardInset(keyboardInset);
|
||||
setChatKeyboardOpen(inputFocused && viewportShrunk);
|
||||
if (viewportShrunk && window.scrollY !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
if (inputFocused) {
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
}
|
||||
@@ -1427,23 +1469,6 @@ export function render({ navigate, route }) {
|
||||
if (!emojiPickerOpen || form.contains(event.target)) return;
|
||||
closeEmojiPicker();
|
||||
});
|
||||
// Enter — отправить; Shift+Enter и Ctrl+Enter — перенос строки.
|
||||
input?.addEventListener('keydown', async (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
if (event.shiftKey || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const text = String(input.value || '').trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
syncComposerLayout();
|
||||
closeEmojiPicker();
|
||||
await sendTextMessage(text);
|
||||
focusInputToEnd();
|
||||
});
|
||||
|
||||
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -1479,7 +1504,8 @@ export function render({ navigate, route }) {
|
||||
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
||||
window.addEventListener('resize', syncKeyboardUi);
|
||||
|
||||
wrap.append(historyLoader, log, form);
|
||||
chrome?.setComposer(form);
|
||||
wrap.append(historyLoader, log);
|
||||
screen.append(wrap);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
@@ -1499,6 +1525,7 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
screen.cleanup = () => {
|
||||
setChatKeyboardOpen(false);
|
||||
setKeyboardInset(0);
|
||||
stopAllTwemojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
||||
|
||||
@@ -84,7 +84,7 @@ function formatChatRowTime(ts) {
|
||||
|
||||
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({ navigate, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const login = String(state.session.login || '').trim();
|
||||
@@ -248,7 +248,8 @@ export function render({ navigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
screen.append(head, divider, list);
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(divider, list);
|
||||
loadList();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -87,13 +87,13 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = state.session.login || profile.login;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profile-screen';
|
||||
|
||||
screen.append(
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: 'Редактирование профиля',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
|
||||
@@ -92,28 +92,32 @@ function shineInfoText() {
|
||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = state.session.login || profile.login;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profile-screen';
|
||||
|
||||
const topActions = document.createElement('div');
|
||||
topActions.className = 'profile-top-actions';
|
||||
topActions.innerHTML = `
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-actions profile-top-actions">
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const topActions = topbar.querySelector('.profile-top-actions');
|
||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack profile-main-card';
|
||||
@@ -264,7 +268,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
card.append(topActions, topRow, badgesRow, listWrap);
|
||||
card.append(topRow, badgesRow, listWrap);
|
||||
screen.append(card);
|
||||
|
||||
updateAvatarUi();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -2,10 +2,13 @@ import {
|
||||
acceptIncomingCall,
|
||||
declineIncomingCall,
|
||||
hangupActiveCall,
|
||||
setCameraEnabled,
|
||||
setMicMuted,
|
||||
subscribeCallState,
|
||||
toggleSpeakerMode,
|
||||
} from './call-service.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from './user-profile-params.js';
|
||||
|
||||
const MINIMIZED_BAR_HEIGHT_PX = 44;
|
||||
|
||||
@@ -15,10 +18,20 @@ let panelEl = null;
|
||||
let headerEl = null;
|
||||
let titleEl = null;
|
||||
let statusEl = null;
|
||||
let mediaStageEl = null;
|
||||
let remoteVideoEl = null;
|
||||
let localPreviewWrapEl = null;
|
||||
let localVideoEl = null;
|
||||
let heroEl = null;
|
||||
let avatarSlotEl = null;
|
||||
let peerLoginEl = null;
|
||||
let kindEl = null;
|
||||
let topStatusEl = null;
|
||||
let incomingActionsEl = null;
|
||||
let activeActionsEl = null;
|
||||
let speakerBtn = null;
|
||||
let muteBtn = null;
|
||||
let cameraBtn = null;
|
||||
let acceptBtn = null;
|
||||
let declineBtn = null;
|
||||
let hangupBtn = null;
|
||||
@@ -28,11 +41,22 @@ let barTextEl = null;
|
||||
let barActionsEl = null;
|
||||
let barSpeakerBtn = null;
|
||||
let barMuteBtn = null;
|
||||
let barCameraBtn = null;
|
||||
let barHangupBtn = null;
|
||||
let unbind = null;
|
||||
let tickerId = 0;
|
||||
let currentSnapshot = null;
|
||||
let minimized = false;
|
||||
let activeAvatarLogin = '';
|
||||
let activeAvatarLoadToken = 0;
|
||||
let localPreviewDragPointerId = null;
|
||||
let localPreviewDragRect = null;
|
||||
let localPreviewDragOffsetX = 0;
|
||||
let localPreviewDragOffsetY = 0;
|
||||
let localPreviewPosition = null;
|
||||
|
||||
const avatarSnapshotCache = new Map();
|
||||
const avatarPendingByLogin = new Map();
|
||||
|
||||
function resolveAppShell() {
|
||||
return document.querySelector('.app-shell') || document.body;
|
||||
@@ -60,7 +84,68 @@ function getSnapshotElapsedMs(snapshot) {
|
||||
function buildBarText(snapshot) {
|
||||
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
|
||||
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
|
||||
return `Звонок с ${peer} · ${elapsed}`;
|
||||
const kind = String(snapshot?.titleText || 'Звонок').trim() || 'Звонок';
|
||||
return `${kind} с ${peer} · ${elapsed}`;
|
||||
}
|
||||
|
||||
async function loadAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (avatarSnapshotCache.has(key)) return avatarSnapshotCache.get(key);
|
||||
if (avatarPendingByLogin.has(key)) return avatarPendingByLogin.get(key);
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
avatarSnapshotCache.set(key, snapshot || null);
|
||||
avatarPendingByLogin.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
avatarSnapshotCache.set(key, null);
|
||||
avatarPendingByLogin.delete(key);
|
||||
return null;
|
||||
});
|
||||
avatarPendingByLogin.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function renderPeerAvatar(login, avatar = null) {
|
||||
const hasAvatar = Boolean(String(avatar?.ar || '').trim());
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: String(login || '').trim() || 'unknown',
|
||||
avatar,
|
||||
size: 'large',
|
||||
className: 'call-peer-avatar',
|
||||
title: login ? `Профиль ${login}` : 'Профиль собеседника',
|
||||
});
|
||||
if (!hasAvatar) {
|
||||
avatarEl.classList.add('call-peer-avatar--empty');
|
||||
const fallbackEl = avatarEl.querySelector('.avatar-fallback');
|
||||
if (fallbackEl) fallbackEl.textContent = '';
|
||||
}
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function updatePeerAvatar(login) {
|
||||
if (!avatarSlotEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (activeAvatarLogin === cleanLogin && avatarSlotEl.childElementCount > 0) return;
|
||||
activeAvatarLogin = cleanLogin;
|
||||
const loadToken = ++activeAvatarLoadToken;
|
||||
avatarSlotEl.innerHTML = '';
|
||||
avatarSlotEl.append(renderPeerAvatar(cleanLogin));
|
||||
if (!cleanLogin) return;
|
||||
void loadAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||
if (!avatarSlotEl || loadToken !== activeAvatarLoadToken || activeAvatarLogin !== cleanLogin) return;
|
||||
const avatar = snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null;
|
||||
const upgraded = renderPeerAvatar(cleanLogin, avatar);
|
||||
avatarSlotEl.replaceChildren(upgraded);
|
||||
});
|
||||
}
|
||||
|
||||
function stopEvent(event) {
|
||||
@@ -68,6 +153,10 @@ function stopEvent(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getIconSvg(name) {
|
||||
if (name === 'mic-on') {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
@@ -87,6 +176,12 @@ function getIconSvg(name) {
|
||||
if (name === 'speaker-off') {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (name === 'camera-on') {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
|
||||
}
|
||||
if (name === 'camera-off') {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -113,6 +208,15 @@ function updateSpeakerButtons(enabled) {
|
||||
setButtonIcon(barSpeakerBtn, iconName, label);
|
||||
}
|
||||
|
||||
function updateCameraButtons(enabled) {
|
||||
const iconName = enabled ? 'camera-on' : 'camera-off';
|
||||
const label = enabled ? 'Выключить камеру' : 'Включить камеру';
|
||||
setButtonIcon(cameraBtn, iconName, label);
|
||||
setButtonIcon(barCameraBtn, iconName, label);
|
||||
if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0';
|
||||
if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0';
|
||||
}
|
||||
|
||||
async function handleMuteClick(event) {
|
||||
stopEvent(event);
|
||||
const nowMuted = event.currentTarget?.dataset?.muted === '1';
|
||||
@@ -134,6 +238,14 @@ async function handleSpeakerClick(event) {
|
||||
await toggleSpeakerMode();
|
||||
}
|
||||
|
||||
async function handleCameraClick(event) {
|
||||
stopEvent(event);
|
||||
const enabled = event.currentTarget?.dataset?.enabled === '1';
|
||||
try {
|
||||
await setCameraEnabled(!enabled);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setMinimized(nextValue) {
|
||||
minimized = Boolean(nextValue);
|
||||
render();
|
||||
@@ -153,6 +265,60 @@ function ensureTicker() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocalPreviewPosition() {
|
||||
if (!localPreviewWrapEl) return;
|
||||
if (!localPreviewPosition) {
|
||||
localPreviewWrapEl.style.left = '';
|
||||
localPreviewWrapEl.style.top = '';
|
||||
localPreviewWrapEl.style.right = '';
|
||||
localPreviewWrapEl.style.bottom = '';
|
||||
return;
|
||||
}
|
||||
localPreviewWrapEl.style.left = `${localPreviewPosition.leftPx}px`;
|
||||
localPreviewWrapEl.style.top = `${localPreviewPosition.topPx}px`;
|
||||
localPreviewWrapEl.style.right = 'auto';
|
||||
localPreviewWrapEl.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function updateLocalPreviewPositionFromPointer(clientX, clientY) {
|
||||
if (!mediaStageEl || !localPreviewWrapEl || !localPreviewDragRect) return;
|
||||
const stageRect = mediaStageEl.getBoundingClientRect();
|
||||
const width = localPreviewDragRect.width;
|
||||
const height = localPreviewDragRect.height;
|
||||
const nextLeft = clamp(clientX - stageRect.left - localPreviewDragOffsetX, 8, Math.max(8, stageRect.width - width - 8));
|
||||
const nextTop = clamp(clientY - stageRect.top - localPreviewDragOffsetY, 8, Math.max(8, stageRect.height - height - 8));
|
||||
localPreviewPosition = {
|
||||
leftPx: Math.round(nextLeft),
|
||||
topPx: Math.round(nextTop),
|
||||
};
|
||||
applyLocalPreviewPosition();
|
||||
}
|
||||
|
||||
function handleLocalPreviewPointerDown(event) {
|
||||
if (!localPreviewWrapEl || event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
localPreviewDragPointerId = event.pointerId;
|
||||
localPreviewDragRect = localPreviewWrapEl.getBoundingClientRect();
|
||||
localPreviewDragOffsetX = event.clientX - localPreviewDragRect.left;
|
||||
localPreviewDragOffsetY = event.clientY - localPreviewDragRect.top;
|
||||
try { localPreviewWrapEl.setPointerCapture(event.pointerId); } catch {}
|
||||
stopEvent(event);
|
||||
}
|
||||
|
||||
function handleLocalPreviewPointerMove(event) {
|
||||
if (localPreviewDragPointerId !== event.pointerId) return;
|
||||
updateLocalPreviewPositionFromPointer(event.clientX, event.clientY);
|
||||
stopEvent(event);
|
||||
}
|
||||
|
||||
function stopLocalPreviewDrag(event = null) {
|
||||
if (event && localPreviewDragPointerId !== event.pointerId) return;
|
||||
if (event && localPreviewWrapEl) {
|
||||
try { localPreviewWrapEl.releasePointerCapture(event.pointerId); } catch {}
|
||||
}
|
||||
localPreviewDragPointerId = null;
|
||||
localPreviewDragRect = null;
|
||||
}
|
||||
|
||||
function ensureUi() {
|
||||
if (rootEl) return;
|
||||
|
||||
@@ -185,12 +351,17 @@ function ensureUi() {
|
||||
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||
barMuteBtn.addEventListener('click', handleMuteClick);
|
||||
|
||||
barCameraBtn = document.createElement('button');
|
||||
barCameraBtn.type = 'button';
|
||||
barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||
barCameraBtn.addEventListener('click', handleCameraClick);
|
||||
|
||||
barHangupBtn = document.createElement('button');
|
||||
barHangupBtn.type = 'button';
|
||||
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
|
||||
barHangupBtn.addEventListener('click', handleEndCallClick);
|
||||
|
||||
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
|
||||
barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn);
|
||||
barEl.append(barTextEl, barActionsEl);
|
||||
|
||||
overlayEl = document.createElement('div');
|
||||
@@ -203,7 +374,7 @@ function ensureUi() {
|
||||
headerEl = document.createElement('div');
|
||||
headerEl.className = 'call-overlay-head';
|
||||
|
||||
titleEl = document.createElement('h2');
|
||||
titleEl = document.createElement('div');
|
||||
titleEl.className = 'call-overlay-title';
|
||||
|
||||
minimizeBtn = document.createElement('button');
|
||||
@@ -219,6 +390,47 @@ function ensureUi() {
|
||||
statusEl = document.createElement('div');
|
||||
statusEl.className = 'call-overlay-status';
|
||||
|
||||
mediaStageEl = document.createElement('div');
|
||||
mediaStageEl.className = 'call-video-stage';
|
||||
|
||||
remoteVideoEl = document.createElement('video');
|
||||
remoteVideoEl.className = 'call-remote-video';
|
||||
remoteVideoEl.autoplay = true;
|
||||
remoteVideoEl.playsInline = true;
|
||||
remoteVideoEl.muted = true;
|
||||
|
||||
localPreviewWrapEl = document.createElement('div');
|
||||
localPreviewWrapEl.className = 'call-local-preview';
|
||||
localPreviewWrapEl.addEventListener('pointerdown', handleLocalPreviewPointerDown);
|
||||
localPreviewWrapEl.addEventListener('pointermove', handleLocalPreviewPointerMove);
|
||||
localPreviewWrapEl.addEventListener('pointerup', stopLocalPreviewDrag);
|
||||
localPreviewWrapEl.addEventListener('pointercancel', stopLocalPreviewDrag);
|
||||
|
||||
localVideoEl = document.createElement('video');
|
||||
localVideoEl.className = 'call-local-video';
|
||||
localVideoEl.autoplay = true;
|
||||
localVideoEl.playsInline = true;
|
||||
localVideoEl.muted = true;
|
||||
|
||||
heroEl = document.createElement('div');
|
||||
heroEl.className = 'call-hero';
|
||||
|
||||
avatarSlotEl = document.createElement('div');
|
||||
avatarSlotEl.className = 'call-peer-avatar-slot';
|
||||
|
||||
peerLoginEl = document.createElement('div');
|
||||
peerLoginEl.className = 'call-peer-login';
|
||||
|
||||
kindEl = document.createElement('div');
|
||||
kindEl.className = 'call-kind';
|
||||
|
||||
topStatusEl = document.createElement('div');
|
||||
topStatusEl.className = 'call-hero-status';
|
||||
|
||||
heroEl.append(avatarSlotEl, peerLoginEl, kindEl, topStatusEl);
|
||||
localPreviewWrapEl.append(localVideoEl);
|
||||
mediaStageEl.append(remoteVideoEl, heroEl, localPreviewWrapEl);
|
||||
|
||||
incomingActionsEl = document.createElement('div');
|
||||
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
|
||||
|
||||
@@ -235,6 +447,11 @@ function ensureUi() {
|
||||
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||
muteBtn.addEventListener('click', handleMuteClick);
|
||||
|
||||
cameraBtn = document.createElement('button');
|
||||
cameraBtn.type = 'button';
|
||||
cameraBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||
cameraBtn.addEventListener('click', handleCameraClick);
|
||||
|
||||
acceptBtn = document.createElement('button');
|
||||
acceptBtn.type = 'button';
|
||||
acceptBtn.className = 'call-accept-btn';
|
||||
@@ -258,9 +475,9 @@ function ensureUi() {
|
||||
hangupBtn.addEventListener('click', handleEndCallClick);
|
||||
|
||||
incomingActionsEl.append(acceptBtn, declineBtn);
|
||||
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn);
|
||||
activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn);
|
||||
headerEl.append(titleEl, minimizeBtn);
|
||||
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl);
|
||||
panelEl.append(mediaStageEl, headerEl, statusEl, incomingActionsEl, activeActionsEl);
|
||||
overlayEl.append(panelEl);
|
||||
rootEl.append(barEl, overlayEl);
|
||||
resolveAppShell().append(rootEl);
|
||||
@@ -270,13 +487,21 @@ function applyExpandedState(snapshot) {
|
||||
overlayEl.hidden = false;
|
||||
barEl.hidden = true;
|
||||
|
||||
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
|
||||
statusEl.textContent = snapshot.statusText || '';
|
||||
titleEl.textContent = '';
|
||||
statusEl.textContent = '';
|
||||
peerLoginEl.textContent = snapshot.peerLogin || 'пользователь';
|
||||
kindEl.textContent = snapshot.titleText || 'Звонок';
|
||||
topStatusEl.textContent = snapshot.statusText || '';
|
||||
updatePeerAvatar(snapshot.peerLogin || '');
|
||||
|
||||
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
|
||||
const muted = Boolean(snapshot.muted);
|
||||
updateMuteButtons(muted);
|
||||
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||
updateCameraButtons(Boolean(snapshot.cameraEnabled));
|
||||
panelEl.classList.toggle('call-overlay-panel--remote-video', Boolean(snapshot.hasRemoteVideo));
|
||||
mediaStageEl.classList.toggle('call-video-stage--remote-video', Boolean(snapshot.hasRemoteVideo));
|
||||
heroEl.hidden = Boolean(snapshot.hasRemoteVideo);
|
||||
|
||||
minimizeBtn.hidden = incomingStage;
|
||||
minimizeBtn.disabled = incomingStage;
|
||||
@@ -290,9 +515,25 @@ function applyExpandedState(snapshot) {
|
||||
muteBtn.hidden = !snapshot.canMute;
|
||||
muteBtn.disabled = !snapshot.canMute;
|
||||
|
||||
cameraBtn.hidden = !snapshot.canToggleCamera;
|
||||
cameraBtn.disabled = !snapshot.canToggleCamera;
|
||||
|
||||
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
|
||||
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
|
||||
hangupBtn.hidden = !snapshot.canHangup;
|
||||
|
||||
mediaStageEl.hidden = false;
|
||||
const remoteStream = snapshot.remoteMediaStream || null;
|
||||
const localStream = snapshot.localPreviewStream || null;
|
||||
const remoteVideoSource = snapshot.hasRemoteVideo ? remoteStream : null;
|
||||
if (remoteVideoEl.srcObject !== remoteVideoSource) remoteVideoEl.srcObject = remoteVideoSource;
|
||||
if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream;
|
||||
remoteVideoEl.hidden = !snapshot.hasRemoteVideo;
|
||||
localPreviewWrapEl.hidden = !snapshot.hasLocalVideo;
|
||||
if (!snapshot.hasLocalVideo) {
|
||||
localPreviewPosition = null;
|
||||
applyLocalPreviewPosition();
|
||||
}
|
||||
}
|
||||
|
||||
function applyMinimizedState(snapshot) {
|
||||
@@ -303,15 +544,18 @@ function applyMinimizedState(snapshot) {
|
||||
const muted = Boolean(snapshot.muted);
|
||||
updateMuteButtons(muted);
|
||||
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||
updateCameraButtons(Boolean(snapshot.cameraEnabled));
|
||||
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
|
||||
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
|
||||
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
|
||||
barMuteBtn.disabled = !snapshot.canMute;
|
||||
barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera;
|
||||
barCameraBtn.disabled = !snapshot.canToggleCamera;
|
||||
const canEnd = !incomingStage && snapshot.canHangup;
|
||||
barHangupBtn.hidden = !canEnd;
|
||||
barHangupBtn.disabled = !canEnd;
|
||||
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
|
||||
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd);
|
||||
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd);
|
||||
}
|
||||
|
||||
function syncShellState() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function resolveShineServerForUserLogin({ login, solanaEndpoint, fa
|
||||
solanaEndpoint,
|
||||
});
|
||||
const accessServers = Array.isArray(parsedUser?.accessServers)
|
||||
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean)
|
||||
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 2)
|
||||
: [];
|
||||
const pickedServerLogin = accessServers[0] || normalizeShineServerLogin(fallbackServerLogin);
|
||||
if (!pickedServerLogin) {
|
||||
|
||||
@@ -30,6 +30,7 @@ const BLOCK_TYPE_TRUSTED_STATE = 70;
|
||||
const SESSIONS_MODE_MIXED = 1;
|
||||
const SESSION_TYPE_USER = 1;
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
const MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
|
||||
let solanaLibPromise = null;
|
||||
function loadSolanaLib() {
|
||||
@@ -41,6 +42,19 @@ function normalizeLogin(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAccessServers(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of Array.isArray(values) ? values : []) {
|
||||
const login = normalizeLogin(value);
|
||||
if (!login || seen.has(login)) continue;
|
||||
seen.add(login);
|
||||
result.push(login);
|
||||
if (result.length >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pushU32LE(buf, value) {
|
||||
const n = Number(value) >>> 0;
|
||||
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
|
||||
@@ -202,7 +216,7 @@ function serializeCreateUserPdaArgs(args) {
|
||||
buf.push(syncServers.length & 0xff);
|
||||
for (const value of syncServers) pushStrU8(buf, value);
|
||||
}
|
||||
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
|
||||
const accessServers = normalizeAccessServers(args.accessServers);
|
||||
buf.push(accessServers.length & 0xff);
|
||||
for (const value of accessServers) pushStrU8(buf, value);
|
||||
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
|
||||
@@ -252,7 +266,7 @@ function serializeUpdateUserPdaArgs(args) {
|
||||
buf.push(syncServers.length & 0xff);
|
||||
for (const value of syncServers) pushStrU8(buf, value);
|
||||
}
|
||||
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
|
||||
const accessServers = normalizeAccessServers(args.accessServers);
|
||||
buf.push(accessServers.length & 0xff);
|
||||
for (const value of accessServers) pushStrU8(buf, value);
|
||||
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
|
||||
@@ -337,7 +351,7 @@ function createPdaState({
|
||||
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
|
||||
serverAddress: serverProfile?.serverAddress ?? '',
|
||||
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
|
||||
accessServers: Array.isArray(accessServers) ? [...accessServers] : [],
|
||||
accessServers: normalizeAccessServers(accessServers),
|
||||
sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff,
|
||||
sessions: Array.isArray(sessions) ? sessions.map((x) => ({
|
||||
sessionType: Number(x?.sessionType || 0) & 0xff,
|
||||
@@ -493,6 +507,7 @@ export function parseShineUserPda(dataBytes) {
|
||||
const accessCount = reader.readU8();
|
||||
accessServers = [];
|
||||
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
|
||||
accessServers = normalizeAccessServers(accessServers);
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_SESSIONS) {
|
||||
|
||||
@@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) {
|
||||
return parts.join(' :: ');
|
||||
}
|
||||
|
||||
export function isSolanaRpcUnavailableError(error) {
|
||||
const details = formatSolanaErrorDetails(error).toLowerCase();
|
||||
return details.includes('service unavailable')
|
||||
|| details.includes('503')
|
||||
|| details.includes('failed to fetch')
|
||||
|| details.includes('fetch failed')
|
||||
|| details.includes('networkerror')
|
||||
|| details.includes('network error')
|
||||
|| details.includes('rpc unavailable')
|
||||
|| details.includes('rpc error')
|
||||
|| details.includes('failed to get info about account');
|
||||
}
|
||||
|
||||
export function isUserAlreadyExistsSolanaError(error) {
|
||||
const details = formatSolanaErrorDetails(error);
|
||||
return details.includes('UserAlreadyExists')
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"name": "СИЯНИЕ",
|
||||
"short_name": "СИЯНИЕ",
|
||||
"start_url": "./index.html",
|
||||
"display": "fullscreen",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"fullscreen",
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0b1020",
|
||||
"theme_color": "#0b1020",
|
||||
"icons": [
|
||||
@@ -21,6 +20,12 @@
|
||||
"src": "./img/logo.jpg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"src": "./img/shine-logo-transparent-final.png",
|
||||
"sizes": "1280x1280",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+686
-53
File diff suppressed because it is too large
Load Diff
+58
-11
@@ -4,6 +4,7 @@ body {
|
||||
background: #05070A;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -22,9 +23,17 @@ body::before {
|
||||
|
||||
.app-shell {
|
||||
width: min(100vw, 430px);
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
--call-minimized-bar-height: 0px;
|
||||
--topbar-height: 0px;
|
||||
--composer-height: 0px;
|
||||
--toolbar-height: 78px;
|
||||
--keyboard-offset: 0px;
|
||||
background: transparent;
|
||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||
@@ -33,6 +42,8 @@ body::before {
|
||||
}
|
||||
|
||||
.screen-content,
|
||||
.topbar-slot,
|
||||
.composer-slot,
|
||||
.toolbar-slot,
|
||||
.connection-retry-banner {
|
||||
z-index: 1;
|
||||
@@ -40,15 +51,16 @@ body::before {
|
||||
|
||||
.screen-content {
|
||||
position: absolute;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 74px;
|
||||
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.screen-content.no-app-chrome {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
bottom: 0;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -62,26 +74,61 @@ body::before {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar-slot[hidden],
|
||||
.composer-slot[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-slot {
|
||||
position: absolute;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topbar-slot > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composer-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
padding: 0 12px 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composer-slot > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toolbar-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .screen-content {
|
||||
bottom: 0;
|
||||
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)));
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .composer-slot {
|
||||
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px));
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .toolbar-slot {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.connection-retry-banner {
|
||||
@@ -125,8 +172,8 @@ body.chat-keyboard-open .toolbar-slot {
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.app-shell {
|
||||
margin: 16px 0;
|
||||
height: calc(100dvh - 32px);
|
||||
top: 16px;
|
||||
height: calc(100svh - 32px);
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background:
|
||||
radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%),
|
||||
radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%),
|
||||
|
||||
@@ -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` отложен в отдельный черновик и в первую итерацию не входит.
|
||||
Reference in New Issue
Block a user