SHA256
Каналы: добавить профиль через TEXT_CHANNEL_META
This commit is contained in:
@@ -36,7 +36,8 @@ public final class BodyRecordParser {
|
|||||||
case TextBody.KEY -> {
|
case TextBody.KEY -> {
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
yield new TextLineBody(subType, version, bodyBytes);
|
yield new TextLineBody(subType, version, bodyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ public final class MsgSubType {
|
|||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||||
|
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
||||||
|
*/
|
||||||
|
public static final short TEXT_CHANNEL_META = 70;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
/** Лайк (LIKE). */
|
/** Лайк (LIKE). */
|
||||||
|
|||||||
+18
-10
@@ -17,10 +17,11 @@ import java.util.Objects;
|
|||||||
* - POST (10)
|
* - POST (10)
|
||||||
* - EDIT_POST (11)
|
* - EDIT_POST (11)
|
||||||
* - REPOST (30)
|
* - REPOST (30)
|
||||||
|
* - CHANNEL_META (70)
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
*
|
*
|
||||||
* POST:
|
* POST / CHANNEL_META:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
@@ -89,8 +90,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = this.subType & 0xFFFF;
|
int st = this.subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
@@ -133,7 +135,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
||||||
|
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
||||||
|
|
||||||
ensureNoTail(bb, "TextLineBody");
|
ensureNoTail(bb, "TextLineBody");
|
||||||
}
|
}
|
||||||
@@ -155,8 +158,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
@@ -206,7 +210,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
@@ -231,10 +236,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||||
} else {
|
} else {
|
||||||
if (message == null || message.isBlank())
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
|
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||||
|
} else if (message == null || message.isBlank()) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -251,7 +259,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
int cap;
|
int cap;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
// EDIT_POST
|
// EDIT_POST
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_1 = 1;
|
public static final int SCHEMA_VERSION_1 = 1;
|
||||||
public static final int SCHEMA_VERSION_2 = 2;
|
public static final int SCHEMA_VERSION_2 = 2;
|
||||||
public static final int SCHEMA_VERSION_3 = 3;
|
public static final int SCHEMA_VERSION_3 = 3;
|
||||||
|
public static final int SCHEMA_VERSION_4 = 4;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ public final class DatabaseInitializer {
|
|||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 30;
|
||||||
|
public static final short TEXT_CHANNEL_META = 70;
|
||||||
|
|
||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
public static final short REACTION_UNLIKE = 2;
|
public static final short REACTION_UNLIKE = 2;
|
||||||
@@ -88,6 +91,10 @@ public final class DatabaseInitializer {
|
|||||||
}
|
}
|
||||||
if (currentVersion < SCHEMA_VERSION_3) {
|
if (currentVersion < SCHEMA_VERSION_3) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_3;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_4) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public final class MsgSubType {
|
|||||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 30;
|
||||||
|
|
||||||
|
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
||||||
|
public static final short TEXT_CHANNEL_META = 70;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
/** Лайк (LIKE). */
|
/** Лайк (LIKE). */
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
|||||||
channel_type_version,
|
channel_type_version,
|
||||||
channel_root_block_number,
|
channel_root_block_number,
|
||||||
channel_root_block_hash,
|
channel_root_block_hash,
|
||||||
created_at_ms
|
created_at_ms,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
ava_ar,
|
||||||
|
ava_sha256,
|
||||||
|
ava_size,
|
||||||
|
meta_updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getSlug());
|
ps.setString(1, entry.getSlug());
|
||||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
|||||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||||
ps.setLong(10, entry.getCreatedAtMs());
|
ps.setLong(10, entry.getCreatedAtMs());
|
||||||
|
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE channel_names_state
|
||||||
|
SET display_name = ?,
|
||||||
|
channel_description = ?,
|
||||||
|
ava_ar = ?,
|
||||||
|
ava_sha256 = ?,
|
||||||
|
ava_size = ?,
|
||||||
|
meta_updated_at_ms = ?
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
|
||||||
|
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||||
|
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.setString(7, entry.getOwnerBlockchainName());
|
||||||
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
|||||||
private int channelRootBlockNumber;
|
private int channelRootBlockNumber;
|
||||||
private byte[] channelRootBlockHash;
|
private byte[] channelRootBlockHash;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private long avaSize;
|
||||||
|
private long metaUpdatedAtMs;
|
||||||
|
|
||||||
public String getSlug() {
|
public String getSlug() {
|
||||||
return slug;
|
return slug;
|
||||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
|||||||
public void setCreatedAtMs(long createdAtMs) {
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
this.createdAtMs = createdAtMs;
|
this.createdAtMs = createdAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAvaAr() {
|
||||||
|
return avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaAr(String avaAr) {
|
||||||
|
this.avaAr = avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvaSha256() {
|
||||||
|
return avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSha256(String avaSha256) {
|
||||||
|
this.avaSha256 = avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getAvaSize() {
|
||||||
|
return avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSize(long avaSize) {
|
||||||
|
this.avaSize = avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getMetaUpdatedAtMs() {
|
||||||
|
return metaUpdatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
|
||||||
|
this.metaUpdatedAtMs = metaUpdatedAtMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE channel_names_state
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 4, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
schema_version = EXCLUDED.schema_version,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -536,7 +536,11 @@ CREATE TABLE IF NOT EXISTS channel_names_state (
|
|||||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
channel_root_block_hash BYTEA NOT NULL,
|
channel_root_block_hash BYTEA NOT NULL,
|
||||||
created_at_ms BIGINT NOT NULL
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
meta_updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||||
|
|||||||
+59
-1
@@ -21,6 +21,7 @@ import server.sync.BlockchainResyncGuard;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import server.sync.AddBlockSyncService;
|
import server.sync.AddBlockSyncService;
|
||||||
@@ -277,6 +278,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChannelNameStateEntry channelNameStateEntry = null;
|
ChannelNameStateEntry channelNameStateEntry = null;
|
||||||
|
ChannelNameStateEntry channelMetaUpdateEntry = null;
|
||||||
Chat200CreateSeed chat200CreateSeed = null;
|
Chat200CreateSeed chat200CreateSeed = null;
|
||||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||||
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||||
@@ -329,6 +331,39 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
||||||
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
|
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
|
||||||
}
|
}
|
||||||
|
} else if ((block.type & 0xFFFF) == 1
|
||||||
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& block.body instanceof TextLineBody metaBody) {
|
||||||
|
int lineCode = metaBody.lineCode();
|
||||||
|
if (lineCode <= 0) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_meta_line", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
ExistingChannelState existingChannel;
|
||||||
|
try {
|
||||||
|
existingChannel = loadExistingChannelState(blockchainName, lineCode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: channel_meta_lookup_failed (login={}, blockchainName={}, lineCode={})",
|
||||||
|
login, blockchainName, lineCode, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
if (existingChannel == null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_not_found", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
ChannelMetaTextParser.Parsed parsedMeta;
|
||||||
|
try {
|
||||||
|
parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, existingChannel.slug);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
channelMetaUpdateEntry = new ChannelNameStateEntry();
|
||||||
|
channelMetaUpdateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
|
channelMetaUpdateEntry.setChannelRootBlockNumber(lineCode);
|
||||||
|
channelMetaUpdateEntry.setDisplayName(parsedMeta.title());
|
||||||
|
channelMetaUpdateEntry.setChannelDescription(parsedMeta.description());
|
||||||
|
channelMetaUpdateEntry.setAvaAr(parsedMeta.avaAr());
|
||||||
|
channelMetaUpdateEntry.setAvaSha256(parsedMeta.avaSha256());
|
||||||
|
channelMetaUpdateEntry.setAvaSize(parsedMeta.avaSize());
|
||||||
|
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4.2) запрет дырок: blockNumber строго last+1
|
// 4.2) запрет дырок: blockNumber строго last+1
|
||||||
@@ -485,7 +520,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
|
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||||
|
|
||||||
if (chat200CreateSeed != null) {
|
if (chat200CreateSeed != null) {
|
||||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||||
@@ -544,6 +579,29 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
long updatedAtMs;
|
long updatedAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class ExistingChannelState {
|
||||||
|
String slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||||
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT slug
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootBlockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
ExistingChannelState state = new ExistingChannelState();
|
||||||
|
state.slug = rs.getString("slug");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
|
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
|
||||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
PreparedStatement ps = c.prepareStatement("""
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
|||||||
+6
-1
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
|
|||||||
BlockchainStateEntry st,
|
BlockchainStateEntry st,
|
||||||
BlockEntry be,
|
BlockEntry be,
|
||||||
UserParamEntry userParamEntry,
|
UserParamEntry userParamEntry,
|
||||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
ChannelNameStateEntry channelNameStateEntry,
|
||||||
|
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
byte[] blockBytes = block.toBytes();
|
byte[] blockBytes = block.toBytes();
|
||||||
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
|
|||||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (channelMetaUpdateEntry != null) {
|
||||||
|
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||||
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
committed = true;
|
committed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class ChannelMetaTextParser {
|
||||||
|
public static final int MAX_TITLE_CHARS = 50;
|
||||||
|
public static final int MAX_DESCRIPTION_CHARS = 250;
|
||||||
|
|
||||||
|
private ChannelMetaTextParser() {}
|
||||||
|
|
||||||
|
public static Parsed parseStrict(String rawText, String fallbackTitle) {
|
||||||
|
String text = String.valueOf(rawText == null ? "" : rawText);
|
||||||
|
String title = "";
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
boolean seenTitle = false;
|
||||||
|
boolean seenAvatar = false;
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
while (text.startsWith("<SHiNE:", offset)) {
|
||||||
|
int end = text.indexOf('>', offset);
|
||||||
|
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
||||||
|
String body = text.substring(offset + "<SHiNE:".length(), end);
|
||||||
|
if (body.startsWith("title;")) {
|
||||||
|
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||||
|
seenTitle = true;
|
||||||
|
title = parseTitleTag(body);
|
||||||
|
} else if (body.startsWith("avatar;")) {
|
||||||
|
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
||||||
|
seenAvatar = true;
|
||||||
|
Avatar avatar = parseAvatarTag(body);
|
||||||
|
avaAr = avatar.ar;
|
||||||
|
avaSha256 = avatar.sha256;
|
||||||
|
avaSize = avatar.size;
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("unknown_channel_meta_tag");
|
||||||
|
}
|
||||||
|
offset = end + 1;
|
||||||
|
if (offset < text.length() && text.charAt(offset) == '\n') offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String description = text.substring(offset).trim();
|
||||||
|
if (countCodePoints(description) > MAX_DESCRIPTION_CHARS) {
|
||||||
|
throw new IllegalArgumentException("channel_meta_description_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
|
String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim();
|
||||||
|
validateTitle(cleanTitle);
|
||||||
|
|
||||||
|
return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseTitleTag(String body) {
|
||||||
|
String prefix = "title;v=1;";
|
||||||
|
if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title");
|
||||||
|
String title = body.substring(prefix.length()).trim();
|
||||||
|
validateTitle(title);
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Avatar parseAvatarTag(String body) {
|
||||||
|
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
|
||||||
|
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
||||||
|
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
||||||
|
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
||||||
|
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
|
||||||
|
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
|
||||||
|
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
|
||||||
|
long size;
|
||||||
|
try {
|
||||||
|
size = Long.parseLong(sizeRaw);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||||
|
}
|
||||||
|
if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||||
|
return new Avatar(ar, sha256, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseFields(String raw) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||||
|
int eq = part.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
String key = part.substring(0, eq).trim();
|
||||||
|
String value = part.substring(eq + 1).trim();
|
||||||
|
if (!key.isEmpty()) out.put(key, value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTitle(String title) {
|
||||||
|
String value = String.valueOf(title == null ? "" : title);
|
||||||
|
if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0
|
||||||
|
|| value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
|
||||||
|
|| value.indexOf('\0') >= 0) {
|
||||||
|
throw new IllegalArgumentException("bad_channel_meta_title_chars");
|
||||||
|
}
|
||||||
|
if (countCodePoints(value) > MAX_TITLE_CHARS) {
|
||||||
|
throw new IllegalArgumentException("channel_meta_title_too_long");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countCodePoints(String value) {
|
||||||
|
return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Avatar(String ar, String sha256, long size) {}
|
||||||
|
|
||||||
|
public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {}
|
||||||
|
}
|
||||||
+127
-50
@@ -23,8 +23,6 @@ final class ChannelsReadSupport {
|
|||||||
static final int MSG_TYPE_REACTION = 2;
|
static final int MSG_TYPE_REACTION = 2;
|
||||||
static final int MSG_TYPE_TECH = 0;
|
static final int MSG_TYPE_TECH = 0;
|
||||||
static final String STORIES_CHANNEL_NAME = "stories";
|
static final String STORIES_CHANNEL_NAME = "stories";
|
||||||
static final String COMMAND_PREFIX = "/.";
|
|
||||||
static final String COMMAND_DESC = "desc";
|
|
||||||
static final String COMMAND_ADD = "add";
|
static final String COMMAND_ADD = "add";
|
||||||
static final String COMMAND_REMOVE = "remove";
|
static final String COMMAND_REMOVE = "remove";
|
||||||
|
|
||||||
@@ -47,11 +45,40 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
if (rootNumber == 0) {
|
if (rootNumber == 0) {
|
||||||
meta.channelName = STORIES_CHANNEL_NAME;
|
meta.channelName = STORIES_CHANNEL_NAME;
|
||||||
meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0);
|
meta.displayName = STORIES_CHANNEL_NAME;
|
||||||
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String stateSql = """
|
||||||
|
SELECT slug, display_name, channel_description, channel_type_code, channel_type_version,
|
||||||
|
ava_ar, ava_sha256, ava_size, meta_updated_at_ms
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
meta.channelName = rs.getString("slug");
|
||||||
|
meta.displayName = rs.getString("display_name");
|
||||||
|
meta.channelDescription = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||||
|
meta.channelTypeCode = rs.getInt("channel_type_code");
|
||||||
|
meta.channelTypeVersion = rs.getInt("channel_type_version");
|
||||||
|
meta.avaAr = String.valueOf(rs.getString("ava_ar") == null ? "" : rs.getString("ava_ar"));
|
||||||
|
meta.avaSha256 = String.valueOf(rs.getString("ava_sha256") == null ? "" : rs.getString("ava_sha256"));
|
||||||
|
meta.avaSize = rs.getLong("ava_size");
|
||||||
|
meta.metaUpdatedAtMs = rs.getLong("meta_updated_at_ms");
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
// fallback ниже для окружений до миграции.
|
||||||
|
}
|
||||||
|
|
||||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
@@ -68,21 +95,20 @@ final class ChannelsReadSupport {
|
|||||||
BodyRecord body = e.body;
|
BodyRecord body = e.body;
|
||||||
if (body instanceof CreateChannelBody ccb) {
|
if (body instanceof CreateChannelBody ccb) {
|
||||||
meta.channelName = ccb.channelName;
|
meta.channelName = ccb.channelName;
|
||||||
|
meta.displayName = ccb.channelName;
|
||||||
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
||||||
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
||||||
} else {
|
} else {
|
||||||
meta.channelName = null;
|
meta.channelName = null;
|
||||||
|
meta.displayName = null;
|
||||||
meta.channelDescription = "";
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||||
}
|
}
|
||||||
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
|
||||||
if (updatedDescription != null) {
|
|
||||||
meta.channelDescription = updatedDescription;
|
|
||||||
}
|
|
||||||
return meta;
|
return meta;
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
meta.channelName = null;
|
meta.channelName = null;
|
||||||
|
meta.displayName = null;
|
||||||
meta.channelDescription = "";
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||||
return meta;
|
return meta;
|
||||||
@@ -106,7 +132,7 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||||
ORDER BY block_number DESC
|
ORDER BY block_number DESC
|
||||||
@@ -270,8 +296,7 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) {
|
if (rootNumber == 0) {
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
|
return "";
|
||||||
return fromCommand == null ? "" : fromCommand;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||||
@@ -287,8 +312,7 @@ final class ChannelsReadSupport {
|
|||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
return saved;
|
||||||
return fromCommand == null ? saved : fromCommand;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (SQLException ignored) {
|
} catch (SQLException ignored) {
|
||||||
@@ -306,8 +330,6 @@ final class ChannelsReadSupport {
|
|||||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||||
BodyRecord body = e.body;
|
BodyRecord body = e.body;
|
||||||
if (body instanceof CreateChannelBody ccb) {
|
if (body instanceof CreateChannelBody ccb) {
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
|
||||||
if (fromCommand != null) return fromCommand;
|
|
||||||
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -363,45 +385,90 @@ final class ChannelsReadSupport {
|
|||||||
return meta.channelTypeVersion;
|
return meta.channelTypeVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = """
|
List<MetaEvent> out = new ArrayList<>();
|
||||||
SELECT block_bytes
|
if (lineCode <= 0) return out;
|
||||||
|
ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode);
|
||||||
|
String rootSql = """
|
||||||
|
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
|
||||||
ORDER BY block_number DESC
|
LIMIT 1
|
||||||
LIMIT 300
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, MSG_TYPE_TECH);
|
||||||
|
ps.setInt(3, 1);
|
||||||
|
ps.setInt(4, lineCode);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
MetaEvent event = new MetaEvent();
|
||||||
|
event.kind = "created";
|
||||||
|
event.login = rs.getString("login");
|
||||||
|
event.bchName = rs.getString("bch_name");
|
||||||
|
event.blockNumber = rs.getInt("block_number");
|
||||||
|
event.blockHash = rs.getBytes("block_hash");
|
||||||
|
event.lineStep = (Integer) rs.getObject("this_line_number");
|
||||||
|
try {
|
||||||
|
BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes"));
|
||||||
|
event.createdAtMs = entry.timestamp * 1000L;
|
||||||
|
if (entry.body instanceof CreateChannelBody ccb) {
|
||||||
|
event.title = ccb.channelName;
|
||||||
|
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
event.createdAtMs = 0L;
|
||||||
|
}
|
||||||
|
if (event.title == null || event.title.isBlank()) {
|
||||||
|
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
|
||||||
|
}
|
||||||
|
if (event.description == null) event.description = "";
|
||||||
|
event.avaAr = "";
|
||||||
|
event.avaSha256 = "";
|
||||||
|
event.avaSize = 0L;
|
||||||
|
out.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||||
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, lineCode);
|
||||||
ps.setInt(5, lineCode);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
|
byte[] bytes = rs.getBytes("block_bytes");
|
||||||
CommandInfo commandInfo = parseCommandText(info.text);
|
TextInfo info = parseTextAndTime(bytes);
|
||||||
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
|
try {
|
||||||
return commandInfo.arg;
|
ChannelMetaTextParser.Parsed parsed = ChannelMetaTextParser.parseStrict(info.text, base.channelName);
|
||||||
|
MetaEvent event = new MetaEvent();
|
||||||
|
event.kind = "profile_updated";
|
||||||
|
event.login = rs.getString("login");
|
||||||
|
event.bchName = rs.getString("bch_name");
|
||||||
|
event.blockNumber = rs.getInt("block_number");
|
||||||
|
event.blockHash = rs.getBytes("block_hash");
|
||||||
|
event.lineStep = (Integer) rs.getObject("this_line_number");
|
||||||
|
event.createdAtMs = info.createdAtMs;
|
||||||
|
event.title = parsed.title();
|
||||||
|
event.description = parsed.description();
|
||||||
|
event.avaAr = parsed.avaAr();
|
||||||
|
event.avaSha256 = parsed.avaSha256();
|
||||||
|
event.avaSize = parsed.avaSize();
|
||||||
|
out.add(event);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Невалидный meta-блок не участвует в системной истории UI.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return out;
|
||||||
}
|
|
||||||
|
|
||||||
static CommandInfo parseCommandText(String text) {
|
|
||||||
String value = String.valueOf(text == null ? "" : text).trim();
|
|
||||||
if (!value.startsWith(COMMAND_PREFIX)) return null;
|
|
||||||
|
|
||||||
String raw = value.substring(COMMAND_PREFIX.length()).trim();
|
|
||||||
if (raw.isEmpty()) return null;
|
|
||||||
|
|
||||||
int sp = raw.indexOf(' ');
|
|
||||||
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
|
|
||||||
String arg = sp < 0 ? "" : raw.substring(sp + 1).trim();
|
|
||||||
if (cmd.isEmpty()) return null;
|
|
||||||
return new CommandInfo(cmd, arg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
||||||
@@ -526,19 +593,29 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static final class ChannelMeta {
|
static final class ChannelMeta {
|
||||||
String channelName;
|
String channelName;
|
||||||
|
String displayName;
|
||||||
String channelDescription;
|
String channelDescription;
|
||||||
int channelTypeCode;
|
int channelTypeCode;
|
||||||
int channelTypeVersion;
|
int channelTypeVersion;
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
long metaUpdatedAtMs = 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class CommandInfo {
|
static final class MetaEvent {
|
||||||
final String command;
|
String kind;
|
||||||
final String arg;
|
String login;
|
||||||
|
String bchName;
|
||||||
CommandInfo(String command, String arg) {
|
int blockNumber;
|
||||||
this.command = command;
|
byte[] blockHash;
|
||||||
this.arg = arg;
|
Integer lineStep;
|
||||||
}
|
long createdAtMs;
|
||||||
|
String title;
|
||||||
|
String description;
|
||||||
|
String avaAr;
|
||||||
|
String avaSha256;
|
||||||
|
long avaSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class PairChannelSelector {
|
static final class PairChannelSelector {
|
||||||
|
|||||||
+26
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||||
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
||||||
channel.setChannelName(meta.channelName);
|
channel.setChannelName(meta.channelName);
|
||||||
|
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||||
channel.setChannelDescription(meta.channelDescription);
|
channel.setChannelDescription(meta.channelDescription);
|
||||||
|
channel.setAvaAr(meta.avaAr);
|
||||||
|
channel.setAvaSha256(meta.avaSha256);
|
||||||
|
channel.setAvaSize(meta.avaSize);
|
||||||
|
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||||
channel.setChannelTypeCode(meta.channelTypeCode);
|
channel.setChannelTypeCode(meta.channelTypeCode);
|
||||||
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
||||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setChannelRoot(rootRef);
|
channel.setChannelRoot(rootRef);
|
||||||
resp.setChannel(channel);
|
resp.setChannel(channel);
|
||||||
|
|
||||||
|
List<Net_GetChannelMessages_Response.ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||||
|
for (ChannelsReadSupport.MetaEvent event : ChannelsReadSupport.channelMetaEvents(c, ownerBch, lineCode)) {
|
||||||
|
Net_GetChannelMessages_Response.ChannelMetaEvent outEvent = new Net_GetChannelMessages_Response.ChannelMetaEvent();
|
||||||
|
outEvent.setKind(event.kind);
|
||||||
|
Net_GetChannelMessages_Response.BlockRef eventRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
|
eventRef.setBlockNumber(event.blockNumber);
|
||||||
|
eventRef.setBlockHash(ChannelsReadSupport.toHex(event.blockHash));
|
||||||
|
outEvent.setMessageRef(eventRef);
|
||||||
|
outEvent.setAuthorLogin(event.login);
|
||||||
|
outEvent.setAuthorBlockchainName(event.bchName);
|
||||||
|
outEvent.setLineStep(event.lineStep);
|
||||||
|
outEvent.setCreatedAtMs(event.createdAtMs);
|
||||||
|
outEvent.setTitle(event.title);
|
||||||
|
outEvent.setDescription(event.description);
|
||||||
|
outEvent.setAvaAr(event.avaAr);
|
||||||
|
outEvent.setAvaSha256(event.avaSha256);
|
||||||
|
outEvent.setAvaSize(event.avaSize);
|
||||||
|
metaEvents.add(outEvent);
|
||||||
|
}
|
||||||
|
resp.setMetaEvents(metaEvents);
|
||||||
|
|
||||||
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
|
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
|
||||||
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
||||||
);
|
);
|
||||||
|
|||||||
+5
@@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setOwnerLogin(key.ownerLogin);
|
channelRef.setOwnerLogin(key.ownerLogin);
|
||||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||||
channelRef.setChannelName(meta.channelName);
|
channelRef.setChannelName(meta.channelName);
|
||||||
|
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||||
channelRef.setChannelDescription(meta.channelDescription);
|
channelRef.setChannelDescription(meta.channelDescription);
|
||||||
|
channelRef.setAvaAr(meta.avaAr);
|
||||||
|
channelRef.setAvaSha256(meta.avaSha256);
|
||||||
|
channelRef.setAvaSize(meta.avaSize);
|
||||||
|
channelRef.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||||
channelRef.setChannelTypeCode(meta.channelTypeCode);
|
channelRef.setChannelTypeCode(meta.channelTypeCode);
|
||||||
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
|
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
|
||||||
channelRef.setPersonal(meta.channelTypeCode == 100);
|
channelRef.setPersonal(meta.channelTypeCode == 100);
|
||||||
|
|||||||
+71
@@ -8,6 +8,7 @@ import java.util.List;
|
|||||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||||
private Channel channel;
|
private Channel channel;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||||
|
|
||||||
public Channel getChannel() { return channel; }
|
public Channel getChannel() { return channel; }
|
||||||
public void setChannel(Channel channel) { this.channel = channel; }
|
public void setChannel(Channel channel) { this.channel = channel; }
|
||||||
@@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
public List<ChannelMetaEvent> getMetaEvents() { return metaEvents; }
|
||||||
|
public void setMetaEvents(List<ChannelMetaEvent> metaEvents) { this.metaEvents = metaEvents; }
|
||||||
|
|
||||||
|
|
||||||
public static class Channel {
|
public static class Channel {
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String displayName;
|
||||||
private String channelDescription;
|
private String channelDescription;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
private Long metaUpdatedAtMs;
|
||||||
private Integer channelTypeCode;
|
private Integer channelTypeCode;
|
||||||
private Integer channelTypeVersion;
|
private Integer channelTypeVersion;
|
||||||
private BlockRef channelRoot;
|
private BlockRef channelRoot;
|
||||||
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getDisplayName() { return displayName; }
|
||||||
|
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||||
|
|
||||||
public String getChannelDescription() { return channelDescription; }
|
public String getChannelDescription() { return channelDescription; }
|
||||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
|
||||||
|
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||||
|
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||||
|
|
||||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||||
|
|
||||||
@@ -47,6 +71,53 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ChannelMetaEvent {
|
||||||
|
private String kind;
|
||||||
|
private BlockRef messageRef;
|
||||||
|
private String authorLogin;
|
||||||
|
private String authorBlockchainName;
|
||||||
|
private Integer lineStep;
|
||||||
|
private long createdAtMs;
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
|
||||||
|
public String getKind() { return kind; }
|
||||||
|
public void setKind(String kind) { this.kind = kind; }
|
||||||
|
|
||||||
|
public BlockRef getMessageRef() { return messageRef; }
|
||||||
|
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||||
|
|
||||||
|
public String getAuthorLogin() { return authorLogin; }
|
||||||
|
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||||
|
|
||||||
|
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||||
|
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||||
|
|
||||||
|
public Integer getLineStep() { return lineStep; }
|
||||||
|
public void setLineStep(Integer lineStep) { this.lineStep = lineStep; }
|
||||||
|
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class MessageItem {
|
public static class MessageItem {
|
||||||
private BlockRef messageRef;
|
private BlockRef messageRef;
|
||||||
private Integer msgSubType;
|
private Integer msgSubType;
|
||||||
|
|||||||
+20
@@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String displayName;
|
||||||
private String channelDescription;
|
private String channelDescription;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
private Long metaUpdatedAtMs;
|
||||||
private Integer channelTypeCode;
|
private Integer channelTypeCode;
|
||||||
private Integer channelTypeVersion;
|
private Integer channelTypeVersion;
|
||||||
private boolean personal;
|
private boolean personal;
|
||||||
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getDisplayName() { return displayName; }
|
||||||
|
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||||
|
|
||||||
public String getChannelDescription() { return channelDescription; }
|
public String getChannelDescription() { return channelDescription; }
|
||||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
|
||||||
|
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||||
|
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||||
|
|
||||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.4.10
|
client.version=1.4.11
|
||||||
server.version=1.4.0
|
server.version=1.4.1
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
- `limit_exceeded`
|
- `limit_exceeded`
|
||||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||||
|
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||||
- `internal_error`
|
- `internal_error`
|
||||||
|
|
||||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
- `TEXT_REPLY (20)`
|
- `TEXT_REPLY (20)`
|
||||||
- `TEXT_EDIT_REPLY (21)`
|
- `TEXT_EDIT_REPLY (21)`
|
||||||
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||||
|
- `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала
|
||||||
|
|
||||||
3. **REACTION (type=2)**
|
3. **REACTION (type=2)**
|
||||||
- `REACTION_LIKE (1)`
|
- `REACTION_LIKE (1)`
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
"ownerLogin": "Alice",
|
"ownerLogin": "Alice",
|
||||||
"ownerBlockchainName": "alice-001",
|
"ownerBlockchainName": "alice-001",
|
||||||
"channelName": "0",
|
"channelName": "0",
|
||||||
|
"displayName": "Мой канал",
|
||||||
|
"channelDescription": "Короткое описание",
|
||||||
|
"avaAr": "ArweaveTxId...",
|
||||||
|
"avaSha256": "0123...",
|
||||||
|
"avaSize": 248193,
|
||||||
|
"metaUpdatedAtMs": 1760000000000,
|
||||||
"personal": true,
|
"personal": true,
|
||||||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||||
},
|
},
|
||||||
@@ -141,8 +147,42 @@
|
|||||||
"ownerLogin": "Bob",
|
"ownerLogin": "Bob",
|
||||||
"ownerBlockchainName": "bob-001",
|
"ownerBlockchainName": "bob-001",
|
||||||
"channelName": "news",
|
"channelName": "news",
|
||||||
|
"displayName": "Новости Bob",
|
||||||
|
"channelDescription": "Канал о новостях",
|
||||||
|
"avaAr": "ArweaveTxId...",
|
||||||
|
"avaSha256": "0123...",
|
||||||
|
"avaSize": 248193,
|
||||||
|
"metaUpdatedAtMs": 1760000000000,
|
||||||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||||
},
|
},
|
||||||
|
"metaEvents": [
|
||||||
|
{
|
||||||
|
"kind": "created",
|
||||||
|
"messageRef": { "blockNumber": 123, "blockHash": "..." },
|
||||||
|
"authorLogin": "Bob",
|
||||||
|
"authorBlockchainName": "bob-001",
|
||||||
|
"lineStep": 1,
|
||||||
|
"createdAtMs": 1760000000000,
|
||||||
|
"title": "news",
|
||||||
|
"description": "",
|
||||||
|
"avaAr": "",
|
||||||
|
"avaSha256": "",
|
||||||
|
"avaSize": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "profile_updated",
|
||||||
|
"messageRef": { "blockNumber": 130, "blockHash": "..." },
|
||||||
|
"authorLogin": "Bob",
|
||||||
|
"authorBlockchainName": "bob-001",
|
||||||
|
"lineStep": 2,
|
||||||
|
"createdAtMs": 1760000500000,
|
||||||
|
"title": "Новости Bob",
|
||||||
|
"description": "Канал о новостях",
|
||||||
|
"avaAr": "ArweaveTxId...",
|
||||||
|
"avaSha256": "0123...",
|
||||||
|
"avaSize": 248193
|
||||||
|
}
|
||||||
|
],
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
# Командные сообщения каналов
|
# Командные сообщения каналов
|
||||||
|
|
||||||
## 1. Общий префикс
|
Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала.
|
||||||
Командные сообщения распознаются по префиксу:
|
|
||||||
|
|
||||||
`/.`
|
## Удалённая команда `/.desc`
|
||||||
|
|
||||||
Пример:
|
Команда `/.desc <text>` больше не поддерживается и не применяется сервером.
|
||||||
- `/.desc Новый комментарий канала`
|
|
||||||
|
|
||||||
## 2. Поддерживаемые команды
|
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
||||||
|
|
||||||
### Для всех типов каналов (`0`, `1`, `100`, `200`)
|
- `msg_type=1`
|
||||||
- `/.desc <text>` — смена описания канала.
|
- `subType=70`
|
||||||
|
- `TEXT_CHANNEL_META`
|
||||||
|
|
||||||
Примечание:
|
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
|
|
||||||
- Если `/.desc` не было, используется описание из `CreateChannel`.
|
|
||||||
|
|
||||||
### Дополнительно для `type=200`
|
## Зарезервированные команды type=200
|
||||||
- `/.add <login> <channelName>`
|
|
||||||
- `/.remove <login> <channelName>`
|
|
||||||
|
|
||||||
Формат аргументов фиксирован: через пробел.
|
Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются.
|
||||||
|
|
||||||
## 3. Текущая модель применения
|
|
||||||
- Команды передаются как обычные `TEXT_POST` сообщения.
|
|
||||||
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
|
|
||||||
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
|
|
||||||
|
|
||||||
## 4. Статус для MVP
|
|
||||||
- В текущем UI каналы `type=100` и `type=200` не используются.
|
|
||||||
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
||||||
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
||||||
|
|
||||||
|
6. `subType=70` — `TEXT_CHANNEL_META`
|
||||||
|
- скрытый технический снимок профиля канала;
|
||||||
|
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
||||||
|
- не отображается как обычное сообщение ленты;
|
||||||
|
- применяется сервером к текущему состоянию канала.
|
||||||
|
|
||||||
|
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# TEXT_CHANNEL_META: профиль канала
|
||||||
|
|
||||||
|
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала.
|
||||||
|
|
||||||
|
## Блок
|
||||||
|
|
||||||
|
- `msg_type = 1`
|
||||||
|
- `subType = 70`
|
||||||
|
- `msgVersion = 1`
|
||||||
|
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
||||||
|
|
||||||
|
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
|
||||||
|
|
||||||
|
## Текстовый формат
|
||||||
|
|
||||||
|
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<SHiNE:title;v=1;Название канала>
|
||||||
|
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||||
|
Описание канала.
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаемые теги:
|
||||||
|
|
||||||
|
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
|
||||||
|
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Каждый `TEXT_CHANNEL_META` задаёт полный снимок состояния профиля канала.
|
||||||
|
- Старые meta-блоки остаются в блокчейне как история, но текущее состояние берётся из последнего валидного meta-блока.
|
||||||
|
- Если `title` отсутствует, используется техническое имя канала.
|
||||||
|
- Если `avatar` отсутствует, аватар считается сброшенным.
|
||||||
|
- Если описание после тегов отсутствует, описание считается пустым.
|
||||||
|
- `meta_updated_at_ms` равен времени самого валидного meta-блока.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Длина `title` — максимум 50 Unicode code points.
|
||||||
|
- В `title` запрещены `<`, `>`, `;`, табуляция, перевод строки и `\0`.
|
||||||
|
- Длина описания — максимум 250 Unicode code points.
|
||||||
|
- `avatar.ar` — Arweave transaction id из 43 символов.
|
||||||
|
- `avatar.sha256` — 64 hex-символа.
|
||||||
|
- `avatar.size` — положительный размер файла в байтах.
|
||||||
|
|
||||||
|
Если meta-блок невалиден, сервер не применяет его целиком.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- При создании канала UI может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, аватаром и описанием.
|
||||||
|
- При создании канала UI показывает системную карточку `Создан канал ...`.
|
||||||
|
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
|
||||||
|
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
|
||||||
|
- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события.
|
||||||
|
|
||||||
|
## Замена старых команд
|
||||||
|
|
||||||
|
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
||||||
@@ -113,6 +113,14 @@
|
|||||||
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
|
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
|
||||||
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
|
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
|
||||||
|
|
||||||
|
## 2026-08-01 01:45:29 +0400
|
||||||
|
- Базовый коммит-ориентир: `24afcba`.
|
||||||
|
- Добавлен новый `TEXT`-подтип `TEXT_CHANNEL_META (subType=70)` для скрытого технического снимка профиля канала.
|
||||||
|
- Зафиксирован формат тегов `<SHiNE:title;v=1;...>` и `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`; описание хранится хвостовым UTF-8 текстом.
|
||||||
|
- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром.
|
||||||
|
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
|
||||||
|
- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`.
|
||||||
|
|
||||||
## 2026-07-31 17:40:31 +0400
|
## 2026-07-31 17:40:31 +0400
|
||||||
- Базовый коммит-ориентир: `d640dd6`.
|
- Базовый коммит-ориентир: `d640dd6`.
|
||||||
- Формат блокчейна не менялся.
|
- Формат блокчейна не менялся.
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
Параметры пользователя (`msg_type=4`).
|
Параметры пользователя (`msg_type=4`).
|
||||||
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
||||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1`.
|
Вложения в TEXT-сообщениях через `SHiNE:attach v=1`.
|
||||||
9. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
9. [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)
|
||||||
Типы каналов и формат `CreateChannelBody`.
|
Типы каналов и формат `CreateChannelBody`.
|
||||||
10. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
11. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||||
Команды в текстовых сообщениях каналов.
|
Команды в текстовых сообщениях каналов.
|
||||||
11. [CHANGELOG.md](./CHANGELOG.md)
|
12. [CHANGELOG.md](./CHANGELOG.md)
|
||||||
Журнал изменений документации.
|
Журнал изменений документации.
|
||||||
|
|
||||||
## Смежная документация
|
## Смежная документация
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ const RECENT_UPLOAD_MS = 20 * 60 * 1000;
|
|||||||
function normalizeHistoryItem(input = {}) {
|
function normalizeHistoryItem(input = {}) {
|
||||||
const item = normalizeAttachment(input);
|
const item = normalizeAttachment(input);
|
||||||
const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0;
|
const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0;
|
||||||
|
const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment';
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
purpose,
|
||||||
pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs,
|
pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs,
|
||||||
placedInShineAtMs,
|
placedInShineAtMs,
|
||||||
};
|
};
|
||||||
@@ -133,6 +135,36 @@ function shortAddress(value) {
|
|||||||
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resizeImageFileToAvatar(file) {
|
||||||
|
const source = file instanceof File ? file : null;
|
||||||
|
if (!source) throw new Error('Выберите изображение.');
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(new Error('Не удалось прочитать изображение.'));
|
||||||
|
reader.readAsDataURL(source);
|
||||||
|
});
|
||||||
|
const image = await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('Не удалось открыть изображение.'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
||||||
|
if (!side || side <= 0) throw new Error('Некорректный размер изображения.');
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 512;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas недоступен.');
|
||||||
|
const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2);
|
||||||
|
const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2);
|
||||||
|
ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512);
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
|
||||||
|
if (!blob) throw new Error('Не удалось сжать аватар.');
|
||||||
|
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
export function openArweaveAttachmentManager({
|
export function openArweaveAttachmentManager({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
@@ -140,6 +172,8 @@ export function openArweaveAttachmentManager({
|
|||||||
onSelect,
|
onSelect,
|
||||||
selectedTxIds = [],
|
selectedTxIds = [],
|
||||||
historyOnly = false,
|
historyOnly = false,
|
||||||
|
mode = 'attachment',
|
||||||
|
historyPurpose = '',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||||
@@ -158,6 +192,8 @@ export function openArweaveAttachmentManager({
|
|||||||
let priceInfo = null;
|
let priceInfo = null;
|
||||||
let balanceInfo = null;
|
let balanceInfo = null;
|
||||||
let autoOpenedFileDialog = false;
|
let autoOpenedFileDialog = false;
|
||||||
|
const isAvatarMode = String(mode || '') === 'avatar';
|
||||||
|
const purposeFilter = isAvatarMode || String(historyPurpose || '').trim() === 'avatar' ? 'avatar' : '';
|
||||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||||
|
|
||||||
function selectedWallet() {
|
function selectedWallet() {
|
||||||
@@ -207,18 +243,18 @@ export function openArweaveAttachmentManager({
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" data-ar-attach-modal="true">
|
<div class="modal" data-ar-attach-modal="true">
|
||||||
<div class="modal-card stack ar-attachment-manager-card">
|
<div class="modal-card stack ar-attachment-manager-card">
|
||||||
<h3 class="modal-title">${historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'}</h3>
|
<h3 class="modal-title">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</h3>
|
||||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||||
<select class="input" id="ar-attach-wallet"></select>
|
<select class="input" id="ar-attach-wallet"></select>
|
||||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||||
${historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>'}
|
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||||
<input class="input" id="ar-attach-file" type="file" />
|
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||||
<div class="ar-attachment-meta" data-meta="true"></div>
|
<div class="ar-attachment-meta" data-meta="true"></div>
|
||||||
<p class="meta-muted inline-error" data-error="true"></p>
|
<p class="meta-muted inline-error" data-error="true"></p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
|
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
|
||||||
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="history">История загрузок</button>'}
|
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
|
||||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||||
@@ -266,13 +302,19 @@ export function openArweaveAttachmentManager({
|
|||||||
try {
|
try {
|
||||||
const wallet = selectedWallet();
|
const wallet = selectedWallet();
|
||||||
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
|
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
|
||||||
|
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
|
||||||
|
throw new Error('Для аватара можно выбрать только изображение.');
|
||||||
|
}
|
||||||
|
if (isAvatarMode) {
|
||||||
|
selectedFile = await resizeImageFileToAvatar(selectedFile);
|
||||||
|
}
|
||||||
const buffer = await selectedFile.arrayBuffer();
|
const buffer = await selectedFile.arrayBuffer();
|
||||||
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
||||||
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
|
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
|
||||||
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
|
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
|
||||||
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
||||||
metaEl.innerHTML = `
|
metaEl.innerHTML = `
|
||||||
<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>
|
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>`}
|
||||||
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
|
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
|
||||||
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
|
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
|
||||||
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||||
@@ -305,19 +347,20 @@ export function openArweaveAttachmentManager({
|
|||||||
gateway: cleanGateway,
|
gateway: cleanGateway,
|
||||||
jwk: wallet.jwk,
|
jwk: wallet.jwk,
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
shineType: 'attachment',
|
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
{ name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' },
|
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
finish(resolve, {
|
finish(resolve, {
|
||||||
name: selectedFile.name || 'file',
|
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
||||||
size: selectedFile.size,
|
size: selectedFile.size,
|
||||||
sha256: selectedSha256,
|
sha256: selectedSha256,
|
||||||
ar: uploaded.id,
|
ar: uploaded.id,
|
||||||
uploadedAtMs: Date.now(),
|
uploadedAtMs: Date.now(),
|
||||||
}, { pendingPlacement: historyOnly });
|
purpose: isAvatarMode ? 'avatar' : 'attachment',
|
||||||
|
}, { pendingPlacement: historyOnly && !isAvatarMode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
|
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
|
||||||
@@ -394,7 +437,10 @@ export function openArweaveAttachmentManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showHistory = () => {
|
const showHistory = () => {
|
||||||
const rows = readArweaveAttachmentHistory(cleanLogin).slice().reverse();
|
const rows = readArweaveAttachmentHistory(cleanLogin)
|
||||||
|
.filter((item) => !purposeFilter || item.purpose === purposeFilter)
|
||||||
|
.slice()
|
||||||
|
.reverse();
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" data-ar-attach-modal="true">
|
<div class="modal" data-ar-attach-modal="true">
|
||||||
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
|
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
|
||||||
@@ -405,7 +451,7 @@ export function openArweaveAttachmentManager({
|
|||||||
const isSelected = selectedTxIdSet.has(item.ar);
|
const isSelected = selectedTxIdSet.has(item.ar);
|
||||||
return `
|
return `
|
||||||
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
|
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
|
||||||
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}</strong>
|
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
|
||||||
<div class="ar-attachment-history-meta-row">
|
<div class="ar-attachment-history-meta-row">
|
||||||
<span>${escapeHtml(formatBytes(item.size))}</span>
|
<span>${escapeHtml(formatBytes(item.size))}</span>
|
||||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { authService, state } from '../state.js';
|
|||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
channelNameErrorText,
|
channelNameErrorText,
|
||||||
normalizeChannelDescription,
|
|
||||||
normalizeChannelDisplayName,
|
normalizeChannelDisplayName,
|
||||||
validateChannelDisplayName,
|
validateChannelDisplayName,
|
||||||
} from '../services/channel-name-rules.js';
|
} from '../services/channel-name-rules.js';
|
||||||
|
import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js';
|
||||||
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||||
|
|
||||||
@@ -21,13 +22,30 @@ function persistCreateSuccessFlash(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateDescription(value) {
|
function normalizeMetaText(value, max, label) {
|
||||||
const normalized = normalizeChannelDescription(value);
|
const normalized = String(value || '').trim();
|
||||||
const bytes = new TextEncoder().encode(normalized).length;
|
const length = Array.from(normalized).length;
|
||||||
if (bytes > 200) {
|
if (length > max) {
|
||||||
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
|
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
|
||||||
}
|
}
|
||||||
return { ok: true, normalized, bytes, error: '' };
|
return { ok: true, normalized, length, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvatarPreview(slot, avatar, title) {
|
||||||
|
if (!slot) return;
|
||||||
|
slot.innerHTML = '';
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'channel-profile-avatar';
|
||||||
|
wrap.style.setProperty('--channel-avatar-size', '82px');
|
||||||
|
if (avatar?.ar) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = 'Аватар канала';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||||
|
wrap.append(img);
|
||||||
|
} else {
|
||||||
|
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||||
|
}
|
||||||
|
slot.append(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
@@ -50,13 +68,21 @@ export function render({ navigate }) {
|
|||||||
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
|
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
|
||||||
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
|
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
|
||||||
|
|
||||||
<label for="channel-name">Название канала</label>
|
<label for="channel-name">Техническое имя канала</label>
|
||||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
|
<label for="channel-title">Человекочитаемое имя</label>
|
||||||
|
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||||
|
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||||
|
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
|
<div id="channel-avatar-preview"></div>
|
||||||
|
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||||
|
|
||||||
<label for="channel-description">Описание канала (необязательно)</label>
|
<label for="channel-description">Описание канала (необязательно)</label>
|
||||||
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||||
@@ -67,8 +93,13 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const nameEl = form.querySelector('#channel-name');
|
const nameEl = form.querySelector('#channel-name');
|
||||||
|
const titleEl = form.querySelector('#channel-title');
|
||||||
const descriptionEl = form.querySelector('#channel-description');
|
const descriptionEl = form.querySelector('#channel-description');
|
||||||
|
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
|
||||||
|
const avatarBtn = form.querySelector('#channel-avatar-btn');
|
||||||
const nameErrorEl = form.querySelector('#channel-name-error');
|
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||||
|
const titleErrorEl = form.querySelector('#channel-title-error');
|
||||||
|
const titleCounterEl = form.querySelector('#channel-title-counter');
|
||||||
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||||
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
||||||
const errorEl = form.querySelector('#channel-create-error');
|
const errorEl = form.querySelector('#channel-create-error');
|
||||||
@@ -76,38 +107,60 @@ export function render({ navigate }) {
|
|||||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||||
|
|
||||||
let submitInFlight = false;
|
let submitInFlight = false;
|
||||||
|
let selectedAvatar = null;
|
||||||
|
|
||||||
const setBusy = (busy) => {
|
const setBusy = (busy) => {
|
||||||
submitInFlight = !!busy;
|
submitInFlight = !!busy;
|
||||||
submitEl.disabled = submitInFlight;
|
submitEl.disabled = submitInFlight;
|
||||||
cancelEl.disabled = submitInFlight;
|
cancelEl.disabled = submitInFlight;
|
||||||
nameEl.disabled = submitInFlight;
|
nameEl.disabled = submitInFlight;
|
||||||
|
titleEl.disabled = submitInFlight;
|
||||||
descriptionEl.disabled = submitInFlight;
|
descriptionEl.disabled = submitInFlight;
|
||||||
|
avatarBtn.disabled = submitInFlight;
|
||||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateValidation = () => {
|
const updateValidation = () => {
|
||||||
const nameCheck = validateChannelDisplayName(nameEl.value);
|
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||||
const descriptionCheck = validateDescription(descriptionEl.value);
|
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
|
||||||
|
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
|
||||||
|
|
||||||
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
||||||
|
titleErrorEl.textContent = titleCheck.error;
|
||||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||||
|
|
||||||
const descLength = Number(descriptionCheck.bytes || 0);
|
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||||
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
|
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||||
|
|
||||||
const ok = nameCheck.ok && descriptionCheck.ok;
|
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||||
submitEl.disabled = submitInFlight || !ok;
|
submitEl.disabled = submitInFlight || !ok;
|
||||||
|
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
name: nameCheck.normalized,
|
name: nameCheck.normalized,
|
||||||
|
title: titleCheck.normalized,
|
||||||
description: descriptionCheck.normalized,
|
description: descriptionCheck.normalized,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
nameEl.addEventListener('input', updateValidation);
|
nameEl.addEventListener('input', updateValidation);
|
||||||
|
titleEl.addEventListener('input', updateValidation);
|
||||||
descriptionEl.addEventListener('input', updateValidation);
|
descriptionEl.addEventListener('input', updateValidation);
|
||||||
|
avatarBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
selectedAvatar = await openArweaveAttachmentManager({
|
||||||
|
login: state.session.login,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
gateway: state.entrySettings.arweaveServer,
|
||||||
|
mode: 'avatar',
|
||||||
|
historyPurpose: 'avatar',
|
||||||
|
});
|
||||||
|
updateValidation();
|
||||||
|
} catch (error) {
|
||||||
|
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -127,14 +180,25 @@ export function render({ navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.addBlockCreateChannel({
|
const createResult = await authService.addBlockCreateChannel({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channelName: normalizeChannelDisplayName(check.name),
|
channelName: normalizeChannelDisplayName(check.name),
|
||||||
channelDescription: normalizeChannelDescription(check.description),
|
channelDescription: '',
|
||||||
channelType: CHANNEL_TYPE_PUBLIC,
|
channelType: CHANNEL_TYPE_PUBLIC,
|
||||||
channelTypeVersion: 1,
|
channelTypeVersion: 1,
|
||||||
});
|
});
|
||||||
|
if (check.title || check.description || selectedAvatar?.ar) {
|
||||||
|
await authService.addBlockChannelMeta({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
channel: createResult.channel,
|
||||||
|
title: check.title,
|
||||||
|
description: check.description,
|
||||||
|
avatar: selectedAvatar,
|
||||||
|
});
|
||||||
|
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
const CHANNEL_TYPE_STORIES = 0;
|
const CHANNEL_TYPE_STORIES = 0;
|
||||||
@@ -286,22 +287,144 @@ function resolveMessageTimestampMs(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAboutChannelModal(channel) {
|
function createChannelAvatarElement(channel, size = 72) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'channel-profile-avatar';
|
||||||
|
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||||
|
const txId = String(channel?.avaAr || '').trim();
|
||||||
|
if (txId) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = 'Аватар канала';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||||
|
wrap.append(img);
|
||||||
|
} else {
|
||||||
|
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="about-channel-modal">
|
<div class="modal" id="about-channel-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack channel-profile-card">
|
||||||
<h3 class="modal-title">О канале</h3>
|
<div class="channel-profile-modal-head">
|
||||||
<p><strong>${channel.displayName || channel.name}</strong></p>
|
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||||
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
|
${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>
|
||||||
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
|
||||||
|
|
||||||
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
});
|
});
|
||||||
|
root.querySelector('#about-channel-edit')?.addEventListener('click', () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
if (typeof onEdit === 'function') onEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAboutChannelModal(channel, options = {}) {
|
||||||
|
openChannelMetaDetailsModal({
|
||||||
|
title: 'О канале',
|
||||||
|
channel,
|
||||||
|
canEdit: options.canEdit === true,
|
||||||
|
onEdit: options.onEdit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditChannelModal({ channel, onSave }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
let selectedAvatar = channel?.avaAr ? {
|
||||||
|
name: 'Аватар',
|
||||||
|
ar: channel.avaAr,
|
||||||
|
sha256: channel.avaSha256,
|
||||||
|
size: channel.avaSize,
|
||||||
|
purpose: 'avatar',
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const renderAvatarPreview = () => {
|
||||||
|
const slot = root.querySelector('#edit-channel-avatar-preview');
|
||||||
|
if (!slot) return;
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.append(createChannelAvatarElement({
|
||||||
|
...channel,
|
||||||
|
avaAr: selectedAvatar?.ar || '',
|
||||||
|
displayTitle: root.querySelector('#edit-channel-title')?.value || channel.displayTitle,
|
||||||
|
}, 86));
|
||||||
|
};
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="edit-channel-modal">
|
||||||
|
<div class="modal-card stack channel-profile-card">
|
||||||
|
<h3 class="modal-title">Изменение канала</h3>
|
||||||
|
<div id="edit-channel-avatar-preview"></div>
|
||||||
|
<button class="secondary-btn" id="edit-channel-avatar" type="button">Выбрать аватар</button>
|
||||||
|
<label class="meta-muted" for="edit-channel-title">Человекочитаемое имя</label>
|
||||||
|
<input class="input" id="edit-channel-title" type="text" maxlength="50" value="${escapeHtml(channel.displayTitle || channel.name || '')}" />
|
||||||
|
<label class="meta-muted" for="edit-channel-description">Описание</label>
|
||||||
|
<textarea class="input" id="edit-channel-description" rows="4" maxlength="250">${escapeHtml(channel.description || '')}</textarea>
|
||||||
|
<p class="meta-muted inline-error" id="edit-channel-error"></p>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="edit-channel-cancel" type="button">Отмена</button>
|
||||||
|
<button class="primary-btn" id="edit-channel-save" type="button">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
renderAvatarPreview();
|
||||||
|
|
||||||
|
root.querySelector('#edit-channel-cancel')?.addEventListener('click', () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
});
|
||||||
|
root.querySelector('#edit-channel-title')?.addEventListener('input', renderAvatarPreview);
|
||||||
|
root.querySelector('#edit-channel-avatar')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const item = await openArweaveAttachmentManager({
|
||||||
|
login: state.session.login,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
gateway: state.entrySettings.arweaveServer,
|
||||||
|
mode: 'avatar',
|
||||||
|
historyPurpose: 'avatar',
|
||||||
|
});
|
||||||
|
if (!item) return;
|
||||||
|
selectedAvatar = item;
|
||||||
|
renderAvatarPreview();
|
||||||
|
} catch (error) {
|
||||||
|
const errorEl = root.querySelector('#edit-channel-error');
|
||||||
|
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.querySelector('#edit-channel-save')?.addEventListener('click', async () => {
|
||||||
|
const saveBtn = root.querySelector('#edit-channel-save');
|
||||||
|
const errorEl = root.querySelector('#edit-channel-error');
|
||||||
|
const title = String(root.querySelector('#edit-channel-title')?.value || '').trim();
|
||||||
|
const description = String(root.querySelector('#edit-channel-description')?.value || '').trim();
|
||||||
|
if (Array.from(title).length > 50) {
|
||||||
|
if (errorEl) errorEl.textContent = 'Название: максимум 50 символов.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.from(description).length > 250) {
|
||||||
|
if (errorEl) errorEl.textContent = 'Описание: максимум 250 символов.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
if (errorEl) errorEl.textContent = '';
|
||||||
|
try {
|
||||||
|
await onSave({ title, description, avatar: selectedAvatar });
|
||||||
|
root.innerHTML = '';
|
||||||
|
} catch (error) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось изменить канал.');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindSubmitOnPlainEnter(textarea, submit) {
|
function bindSubmitOnPlainEnter(textarea, submit) {
|
||||||
@@ -941,11 +1064,17 @@ async function loadFromApi(route, channelId) {
|
|||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
|
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: String(payload.channel?.channelDescription || '').trim(),
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
|
avaAr: String(payload.channel?.avaAr || '').trim(),
|
||||||
|
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
|
||||||
|
avaSize: Number(payload.channel?.avaSize || 0),
|
||||||
|
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
|
||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
|
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||||
reverseChannelMissingWarning,
|
reverseChannelMissingWarning,
|
||||||
isOwnChannel,
|
isOwnChannel,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
@@ -1033,6 +1162,48 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
|||||||
setTimeout(doScroll, 20);
|
setTimeout(doScroll, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapChannelMetaEvent(event, fallbackChannel) {
|
||||||
|
return {
|
||||||
|
kind: String(event?.kind || '').trim(),
|
||||||
|
messageRef: event?.messageRef || null,
|
||||||
|
timestampMs: Number(event?.createdAtMs || 0),
|
||||||
|
title: String(event?.title || fallbackChannel?.displayTitle || fallbackChannel?.name || '').trim(),
|
||||||
|
description: String(event?.description || '').trim(),
|
||||||
|
avaAr: String(event?.avaAr || '').trim(),
|
||||||
|
avaSha256: String(event?.avaSha256 || '').trim(),
|
||||||
|
avaSize: Number(event?.avaSize || 0),
|
||||||
|
ownerName: fallbackChannel?.ownerName || '',
|
||||||
|
name: fallbackChannel?.name || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
openChannelMetaDetailsModal({
|
||||||
|
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён',
|
||||||
|
channel: {
|
||||||
|
name: event.name,
|
||||||
|
displayTitle: event.title,
|
||||||
|
description: event.description,
|
||||||
|
avaAr: event.avaAr,
|
||||||
|
avaSha256: event.avaSha256,
|
||||||
|
avaSize: event.avaSize,
|
||||||
|
ownerName: event.ownerName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPostCard(post, {
|
function renderPostCard(post, {
|
||||||
navigate,
|
navigate,
|
||||||
selector,
|
selector,
|
||||||
@@ -1303,10 +1474,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
feed.className = 'stack channel-feed';
|
||||||
const postsByKey = new Map();
|
const postsByKey = new Map();
|
||||||
|
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||||
|
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||||
|
const feedItems = [
|
||||||
|
...metaEvents.map((event) => ({
|
||||||
|
type: 'meta',
|
||||||
|
timestampMs: event.timestampMs,
|
||||||
|
blockNumber: Number(event?.messageRef?.blockNumber || 0),
|
||||||
|
event,
|
||||||
|
})),
|
||||||
|
...channelData.posts.map((post) => ({
|
||||||
|
type: 'post',
|
||||||
|
timestampMs: post.timestampMs,
|
||||||
|
blockNumber: Number(post?.messageRef?.blockNumber || 0),
|
||||||
|
post,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const byTime = Number(a.timestampMs || 0) - Number(b.timestampMs || 0);
|
||||||
|
if (byTime !== 0) return byTime;
|
||||||
|
return Number(a.blockNumber || 0) - Number(b.blockNumber || 0);
|
||||||
|
});
|
||||||
|
|
||||||
if (channelData.posts.length) {
|
if (feedItems.length) {
|
||||||
channelData.posts.forEach((post) => {
|
feedItems.forEach((item) => {
|
||||||
const row = renderPostCard(post, {
|
if (item.type === 'meta') {
|
||||||
|
feed.append(renderChannelMetaEventCard(item.event));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = renderPostCard(item.post, {
|
||||||
navigate,
|
navigate,
|
||||||
selector: channelData.selector,
|
selector: channelData.selector,
|
||||||
onToggleLike: handlers.onToggleLike,
|
onToggleLike: handlers.onToggleLike,
|
||||||
@@ -1315,9 +1510,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
onShare: handlers.onShare,
|
onShare: handlers.onShare,
|
||||||
onEdit: handlers.onEdit,
|
onEdit: handlers.onEdit,
|
||||||
});
|
});
|
||||||
const key = messageRefKey(post.messageRef);
|
const key = messageRefKey(item.post.messageRef);
|
||||||
if (key) {
|
if (key) {
|
||||||
postsByKey.set(key, post);
|
postsByKey.set(key, item.post);
|
||||||
}
|
}
|
||||||
feed.append(row);
|
feed.append(row);
|
||||||
});
|
});
|
||||||
@@ -1565,6 +1760,25 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEditChannelMeta = async ({ title, description, avatar }) => {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||||
|
throw new Error('Идентификатор канала не готов.');
|
||||||
|
}
|
||||||
|
await authService.addBlockChannelMeta({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
channel: activeSelector,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
|
||||||
|
softHaptic(12);
|
||||||
|
showToast('Профиль канала обновлён');
|
||||||
|
rerender();
|
||||||
|
};
|
||||||
|
|
||||||
screen.append(header);
|
screen.append(header);
|
||||||
screen.append(statusBox);
|
screen.append(statusBox);
|
||||||
|
|
||||||
@@ -1576,14 +1790,19 @@ export function render({ navigate, route }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
activeSelector = apiData?.selector || null;
|
||||||
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'} / ${apiData?.channel?.name || 'channel'}`;
|
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||||
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
|
|
||||||
if (channelHeaderButton) {
|
if (channelHeaderButton) {
|
||||||
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
channelHeaderButton.textContent = titleLabel;
|
||||||
channelHeaderButton.disabled = false;
|
channelHeaderButton.disabled = false;
|
||||||
channelHeaderButton.onclick = (event) => {
|
channelHeaderButton.onclick = (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openAboutChannelModal(apiData.channel);
|
openAboutChannelModal(apiData.channel, {
|
||||||
|
canEdit: apiData?.isOwnChannel === true,
|
||||||
|
onEdit: () => openEditChannelModal({
|
||||||
|
channel: apiData.channel,
|
||||||
|
onSave: onEditChannelMeta,
|
||||||
|
}),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
@@ -652,11 +653,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
|
|
||||||
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
|
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
|
||||||
const channelName = summary?.channel?.channelName || '(без названия)';
|
const channelName = summary?.channel?.channelName || '(без названия)';
|
||||||
|
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
|
||||||
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
|
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
|
||||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const title = `${ownerLogin} / ${channelName}`;
|
const title = displayTitle || channelName;
|
||||||
|
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
@@ -665,9 +668,12 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||||
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
|
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
|
||||||
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
|
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
|
||||||
avatar: avatarLetterFromName(channelName),
|
avatar: avatarLetterFromName(displayTitle || channelName),
|
||||||
|
avaAr: String(summary?.channel?.avaAr || '').trim(),
|
||||||
title,
|
title,
|
||||||
|
technicalLabel,
|
||||||
channelName,
|
channelName,
|
||||||
|
displayTitle,
|
||||||
channelDescription,
|
channelDescription,
|
||||||
channelTypeCode,
|
channelTypeCode,
|
||||||
channelTypeVersion,
|
channelTypeVersion,
|
||||||
@@ -968,6 +974,10 @@ function renderChannelMain(channel) {
|
|||||||
title.className = 'channel-row-title';
|
title.className = 'channel-row-title';
|
||||||
title.textContent = channel.title;
|
title.textContent = channel.title;
|
||||||
|
|
||||||
|
const technical = document.createElement('p');
|
||||||
|
technical.className = 'channel-row-technical';
|
||||||
|
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
||||||
|
|
||||||
if (channel.channelDescription) {
|
if (channel.channelDescription) {
|
||||||
const desc = document.createElement('p');
|
const desc = document.createElement('p');
|
||||||
desc.className = 'channel-row-description';
|
desc.className = 'channel-row-description';
|
||||||
@@ -983,7 +993,7 @@ function renderChannelMain(channel) {
|
|||||||
meta.className = 'channel-row-owner channel-counter-meta';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.prepend(title);
|
main.prepend(title, technical);
|
||||||
main.append(preview, meta);
|
main.append(preview, meta);
|
||||||
return main;
|
return main;
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1022,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = 'avatar';
|
avatar.className = 'avatar';
|
||||||
avatar.textContent = channel.avatar;
|
if (channel.avaAr) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = '';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||||
|
avatar.append(img);
|
||||||
|
} else {
|
||||||
|
avatar.textContent = channel.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
const main = renderChannelMain(channel);
|
const main = renderChannelMain(channel);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
|||||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||||
const MSG_SUBTYPE_TEXT_REPOST = 30;
|
const MSG_SUBTYPE_TEXT_REPOST = 30;
|
||||||
|
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
|
||||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||||
@@ -698,6 +699,39 @@ function normalizeChannelDescription(value) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateChannelMetaTitle(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
|
||||||
|
if (/[<>;\t\n\r\u0000]/u.test(text)) {
|
||||||
|
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelMetaDescription(value) {
|
||||||
|
const text = String(value == null ? '' : value).trim();
|
||||||
|
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
|
||||||
|
const cleanTitle = validateChannelMetaTitle(title);
|
||||||
|
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||||
|
const rows = [];
|
||||||
|
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
||||||
|
if (avatar?.ar) {
|
||||||
|
const size = Number(avatar.size || 0);
|
||||||
|
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
||||||
|
const ar = String(avatar.ar || '').trim();
|
||||||
|
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||||
|
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
||||||
|
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
||||||
|
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
|
||||||
|
}
|
||||||
|
if (cleanDescription) rows.push(cleanDescription);
|
||||||
|
return rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function validatePersonalChannelName(value) {
|
function validatePersonalChannelName(value) {
|
||||||
const normalized = normalizeChannelDisplayName(value);
|
const normalized = normalizeChannelDisplayName(value);
|
||||||
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||||||
@@ -806,6 +840,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||||
|
const message = String(text || '').trim();
|
||||||
|
const textBytes = utf8Bytes(message);
|
||||||
|
if (textBytes.length > 65535) {
|
||||||
|
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return concatBytes(
|
||||||
|
int32Bytes(lineCode),
|
||||||
|
int32Bytes(prevLineNumber),
|
||||||
|
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||||
|
int32Bytes(thisLineNumber),
|
||||||
|
int16Bytes(textBytes.length),
|
||||||
|
textBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||||
const cleanBch = String(target?.blockchainName || '').trim();
|
const cleanBch = String(target?.blockchainName || '').trim();
|
||||||
const cleanBlockNumber = Number(target?.blockNumber);
|
const cleanBlockNumber = Number(target?.blockNumber);
|
||||||
@@ -2141,6 +2192,67 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveChannelLineTail({ login, blockchainName, channel }) {
|
||||||
|
const selector = channel || {};
|
||||||
|
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
|
||||||
|
const lineCode = Number(selector?.channelRootBlockNumber);
|
||||||
|
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||||
|
throw new Error('Invalid channel selector');
|
||||||
|
}
|
||||||
|
if (ownerBlockchainName !== blockchainName) {
|
||||||
|
throw new Error('Posting is allowed only to your own channels');
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||||
|
if (rootHashHex === ZERO64) {
|
||||||
|
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
||||||
|
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||||
|
if (!rootChannel) throw new Error('Channel root not found');
|
||||||
|
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevLineNumber = lineCode;
|
||||||
|
let prevLineHashHex = rootHashHex;
|
||||||
|
let thisLineNumber = 0;
|
||||||
|
try {
|
||||||
|
const latestPayload = await this.getChannelMessages({
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: lineCode,
|
||||||
|
channelRootBlockHash: rootHashHex,
|
||||||
|
}, 1, 'desc', login);
|
||||||
|
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||||
|
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
|
||||||
|
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
|
||||||
|
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
|
||||||
|
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
|
||||||
|
? latestMeta
|
||||||
|
: latestMessage;
|
||||||
|
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
|
||||||
|
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
|
||||||
|
const latestLineStep = resolveLatestLineStep(latest);
|
||||||
|
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||||
|
prevLineNumber = latestBlockNumber;
|
||||||
|
prevLineHashHex = latestBlockHash;
|
||||||
|
thisLineNumber = Number.isFinite(latestLineStep)
|
||||||
|
? Math.max(0, latestLineStep + 1)
|
||||||
|
: Math.max(1, latestBlockNumber === lineCode ? 1 : 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to root anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerBlockchainName,
|
||||||
|
lineCode,
|
||||||
|
rootHashHex,
|
||||||
|
prevLineNumber,
|
||||||
|
prevLineHashHex,
|
||||||
|
thisLineNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login');
|
if (!cleanLogin) throw new Error('Missing login');
|
||||||
@@ -2154,53 +2266,13 @@ export class AuthService {
|
|||||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||||
|
|
||||||
const ownerBlockchainName = owner;
|
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||||
const lineCode = root;
|
|
||||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
|
||||||
throw new Error('Invalid channel selector');
|
|
||||||
}
|
|
||||||
if (ownerBlockchainName !== blockchainName) {
|
|
||||||
throw new Error('Posting is allowed only to your own channels');
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
|
||||||
if (rootHashHex === ZERO64) {
|
|
||||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
|
||||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
|
||||||
if (!rootChannel) throw new Error('Channel root not found');
|
|
||||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevLineNumber = lineCode;
|
|
||||||
let prevLineHashHex = rootHashHex;
|
|
||||||
let thisLineNumber = 0;
|
|
||||||
try {
|
|
||||||
const latestPayload = await this.getChannelMessages({
|
|
||||||
ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: lineCode,
|
|
||||||
channelRootBlockHash: rootHashHex,
|
|
||||||
}, 1, 'desc', cleanLogin);
|
|
||||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
|
||||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
|
||||||
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
|
||||||
const latestLineStep = resolveLatestLineStep(latestMessage);
|
|
||||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
|
||||||
prevLineNumber = latestBlockNumber;
|
|
||||||
prevLineHashHex = latestBlockHash;
|
|
||||||
// Для нового POST берём следующий шаг после последнего сообщения линии.
|
|
||||||
thisLineNumber = Number.isFinite(latestLineStep)
|
|
||||||
? Math.max(0, latestLineStep + 1)
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fallback to root anchor
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyBytes = makeTextPostBodyBytes({
|
const bodyBytes = makeTextPostBodyBytes({
|
||||||
lineCode,
|
lineCode: tail.lineCode,
|
||||||
prevLineNumber,
|
prevLineNumber: tail.prevLineNumber,
|
||||||
prevLineHashHex,
|
prevLineHashHex: tail.prevLineHashHex,
|
||||||
thisLineNumber,
|
thisLineNumber: tail.thisLineNumber,
|
||||||
text: cleanText,
|
text: cleanText,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2216,9 +2288,50 @@ export class AuthService {
|
|||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
channel: {
|
channel: {
|
||||||
ownerBlockchainName,
|
ownerBlockchainName: tail.ownerBlockchainName,
|
||||||
channelRootBlockNumber: lineCode,
|
channelRootBlockNumber: tail.lineCode,
|
||||||
channelRootBlockHash: rootHashHex,
|
channelRootBlockHash: tail.rootHashHex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
|
||||||
|
const cleanLogin = (login || '').trim();
|
||||||
|
if (!cleanLogin) throw new Error('Missing login');
|
||||||
|
const selector = channel || {};
|
||||||
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||||
|
const root = Number(selector?.channelRootBlockNumber);
|
||||||
|
const metaText = composeChannelMetaText({ title, description, avatar });
|
||||||
|
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
|
||||||
|
|
||||||
|
return this.runWriteLocked(key, async () => {
|
||||||
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||||
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||||
|
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||||
|
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
|
||||||
|
lineCode: tail.lineCode,
|
||||||
|
prevLineNumber: tail.prevLineNumber,
|
||||||
|
prevLineHashHex: tail.prevLineHashHex,
|
||||||
|
thisLineNumber: tail.thisLineNumber,
|
||||||
|
text: metaText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await this.addBlockSigned({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd,
|
||||||
|
msgType: MSG_TYPE_TEXT,
|
||||||
|
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
||||||
|
msgVersion: 1,
|
||||||
|
bodyBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
channel: {
|
||||||
|
ownerBlockchainName: tail.ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: tail.lineCode,
|
||||||
|
channelRootBlockHash: tail.rootHashHex,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3884,6 +3884,66 @@ textarea.input {
|
|||||||
padding-bottom: 9px;
|
padding-bottom: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-system-event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-color: rgba(255, 210, 130, 0.22);
|
||||||
|
background: rgba(89, 67, 31, 0.22);
|
||||||
|
color: rgba(255, 235, 188, 0.92);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-system-event-card small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-profile-card {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-profile-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-profile-edit-btn {
|
||||||
|
width: 38px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-profile-avatar {
|
||||||
|
--channel-avatar-size: 72px;
|
||||||
|
width: var(--channel-avatar-size);
|
||||||
|
height: var(--channel-avatar-size);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: center;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65));
|
||||||
|
border: 1px solid rgba(255, 226, 155, 0.24);
|
||||||
|
color: rgba(255, 236, 194, 0.95);
|
||||||
|
font-size: calc(var(--channel-avatar-size) * 0.42);
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-profile-avatar img,
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.deleted-message-pill {
|
.deleted-message-pill {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -4983,8 +5043,19 @@ textarea.input {
|
|||||||
|
|
||||||
.channel-row-title {
|
.channel-row-title {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 15px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-technical {
|
||||||
|
margin: -2px 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.46);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-row-message {
|
.channel-row-message {
|
||||||
|
|||||||
Reference in New Issue
Block a user