Compare commits

..
Author SHA256 Message Date
malvviiina b686b12c6a Обновить запуск UI и упростить регистрацию 2026-08-06 22:53:44 +03:00
AidarKC e4b903b13c вкладки в каналах 2026-08-06 22:45:15 +04:00
AidarKC 72f576a70e Версия UI 1.5.0 для merge в main 2026-08-06 22:44:11 +04:00
AidarKC c4f2c7be63 UI вложений и черновики форматов каналов 2026-08-06 12:39:33 +04:00
AidarKC 75b75fd223 Добавить Turbo-загрузку и превью для вложений 2026-08-04 19:09:54 +04:00
AidarKC 391b18a5ac Deploy: задать production VAPID keys для UI 2026-08-03 08:34:34 +04:00
AidarKC 8de0f8e87c UI: писать SHiNE в ссылках каналов 2026-08-01 10:12:11 +04:00
AidarKC adb0b76559 UI: запоминать Arweave-кошелёк загрузчика 2026-08-01 09:59:25 +04:00
AidarKC b6df25c02c UI: скрыть неактивные стрелки вложений 2026-08-01 09:47:09 +04:00
AidarKC 620186db1c Документация: уточнить AddBlock для вложений и профиля каналов 2026-08-01 09:43:51 +04:00
AidarKC 21fac8046e UI: сделать плитки каналов почти во всю ширину 2026-08-01 02:59:15 +04:00
AidarKC de7c35f587 UI: расширить плитки каналов 2026-08-01 02:52:25 +04:00
AidarKC a23c979045 UI: обрезать аватары каналов кругом 2026-08-01 02:42:11 +04:00
AidarKC 13da7287b1 UI: скрыть аватары из истории вложений 2026-08-01 02:40:08 +04:00
AidarKC e8398df9fc Каналы: исправить редактирование профиля 2026-08-01 02:29:11 +04:00
AidarKC 8303682bd7 Каналы: создавать профиль одним блоком 2026-08-01 02:19:29 +04:00
AidarKC 8125d1a124 Каналы: исправить загрузку meta-событий 2026-08-01 02:04:48 +04:00
AidarKC 110ff5e12e Каналы: добавить профиль через TEXT_CHANNEL_META 2026-08-01 01:57:41 +04:00
AidarKC 24afcba20a UI: скрыть действия у удалённых сообщений 2026-08-01 01:02:49 +04:00
AidarKC d5f41d12e8 UI: добавить просмотр данных блокчейна сообщения 2026-07-31 17:41:30 +04:00
AidarKC d640dd66e2 UI: улучшить превью и файлы вложений 2026-07-31 17:22:48 +04:00
AidarKC 05bd9f57d6 UI: закрепить панель журнала загрузок 2026-07-31 17:14:01 +04:00
AidarKC 60ab476ec7 UI: сделать плитки загрузок тоньше 2026-07-31 17:07:33 +04:00
AidarKC bcae285ab3 UI: поправить экран загрузки файлов 2026-07-31 16:59:04 +04:00
AidarKC cd67176b41 UI: уплотнить журнал загрузок Arweave 2026-07-31 13:50:17 +04:00
AidarKC 98eba54621 UI: добавить журнал загрузок Arweave 2026-07-31 13:13:58 +04:00
AidarKC c530627078 UI: добавить карусель вложений 2026-07-31 12:49:24 +04:00
AidarKC f961947e1e Документация и UI: уточнить формат вложений 2026-07-30 11:14:19 +04:00
AidarKC e9e6628b21 UI: добавить вложения Arweave в каналы 2026-07-30 11:04:50 +04:00
AidarKC c7684d6fe1 Merge migration-postgres into main 2026-07-30 09:48:13 +04:00
56 changed files with 6120 additions and 1235 deletions
@@ -36,7 +36,8 @@ public final class BodyRecordParser {
case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_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);
}
@@ -70,6 +70,12 @@ public final class MsgSubType {
*/
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) ===================== */
/** Лайк (LIKE). */
@@ -19,7 +19,7 @@ import java.util.Objects;
* [1] channelNameLen
* [N] channelName UTF-8
* [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes)
* [M] channelDescription UTF-8 (0..2048 bytes)
* [2] channelTypeCode (uint16)
* [2] channelTypeVersion (uint16)
*/
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static final byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 32;
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
public final short subType;
public final short version;
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
if (bb.remaining() != descriptionLen + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int typeCode = Short.toUnsignedInt(channelTypeCode);
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static String normalizeDescription(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
@Override
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
}
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
@Override
public int lineSeq() { return thisLineNumber; }
}
@@ -17,10 +17,11 @@ import java.util.Objects;
* - POST (10)
* - EDIT_POST (11)
* - REPOST (30)
* - CHANNEL_META (70)
*
* Формат bodyBytes (BigEndian):
*
* POST:
* POST / CHANNEL_META:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
@@ -89,8 +90,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& 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);
@@ -133,7 +135,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
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");
}
@@ -155,8 +158,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& 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");
@@ -206,7 +210,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_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);
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)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
} 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");
}
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;
@@ -251,7 +259,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
}
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;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
@@ -20,9 +20,11 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_1 = 1;
public static final int SCHEMA_VERSION_2 = 2;
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_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_V4_RESOURCE = "postgres/migration_v4.sql";
private DatabaseInitializer() {}
@@ -31,6 +33,7 @@ public final class DatabaseInitializer {
public static final short TEXT_REPLY = 20;
public static final short TEXT_EDIT_REPLY = 21;
public static final short TEXT_REPOST = 30;
public static final short TEXT_CHANNEL_META = 70;
public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2;
@@ -88,6 +91,10 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_3) {
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 на оригинал). */
public static final short TEXT_REPOST = 30;
/** CHANNEL_META — скрытый технический снимок профиля канала. */
public static final short TEXT_CHANNEL_META = 70;
/* ===================== REACTION (msg_type=2) ===================== */
/** Лайк (LIKE). */
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
channel_type_version,
channel_root_block_number,
channel_root_block_hash,
created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at_ms,
ava_ar,
ava_sha256,
ava_size,
meta_updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug());
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
ps.setInt(8, entry.getChannelRootBlockNumber());
ps.setBytes(9, entry.getChannelRootBlockHash());
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();
}
}
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
private int channelRootBlockNumber;
private byte[] channelRootBlockHash;
private long createdAtMs;
private String avaAr;
private String avaSha256;
private long avaSize;
private long metaUpdatedAtMs;
public String getSlug() {
return slug;
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
public void setCreatedAtMs(long 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_root_block_number INTEGER 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
@@ -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.entyties.Net_AddBlock_Request;
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.WireCodes;
import server.sync.AddBlockSyncService;
@@ -277,6 +278,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
ChannelNameStateEntry channelNameStateEntry = null;
ChannelNameStateEntry channelMetaUpdateEntry = null;
Chat200CreateSeed chat200CreateSeed = null;
if (block.body instanceof CreateChannelBody createChannelBody) {
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
@@ -302,14 +304,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
String displayName = normalizedName;
String channelDescription = createChannelBody.channelDescription == null
? ""
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
try {
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, normalizedName);
displayName = createMeta.title();
channelDescription = createMeta.description();
avaAr = createMeta.avaAr();
avaSha256 = createMeta.avaSha256();
avaSize = createMeta.avaSize();
metaUpdatedAtMs = block.timestamp * 1000L;
} catch (IllegalArgumentException e) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
}
}
channelNameStateEntry = new ChannelNameStateEntry();
channelNameStateEntry.setSlug(slug);
channelNameStateEntry.setDisplayName(normalizedName);
channelNameStateEntry.setChannelDescription(
createChannelBody.channelDescription == null
? ""
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
);
channelNameStateEntry.setDisplayName(displayName);
channelNameStateEntry.setChannelDescription(channelDescription);
channelNameStateEntry.setAvaAr(avaAr);
channelNameStateEntry.setAvaSha256(avaSha256);
channelNameStateEntry.setAvaSize(avaSize);
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
channelNameStateEntry.setOwnerLogin(login);
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
@@ -329,6 +353,39 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
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
@@ -485,7 +542,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) {
upsertChat200StateFromCreate(chat200CreateSeed);
@@ -544,6 +601,29 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
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 {
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
BlockchainStateEntry st,
BlockEntry be,
UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
ChannelNameStateEntry channelNameStateEntry,
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes();
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
channelNameStateDAO.insert(c, channelNameStateEntry);
}
if (channelMetaUpdateEntry != null) {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
c.commit();
committed = true;
} catch (Exception e) {
@@ -0,0 +1,112 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import java.util.HashMap;
import java.util.Map;
public final class ChannelMetaTextParser {
public static final int MAX_TITLE_CHARS = 50;
public static final int MAX_DESCRIPTION_CHARS = 250;
private ChannelMetaTextParser() {}
public static Parsed parseStrict(String rawText, String fallbackTitle) {
String text = String.valueOf(rawText == null ? "" : rawText);
String title = "";
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
boolean seenTitle = false;
boolean seenAvatar = false;
int offset = 0;
while (text.startsWith("<SHiNE:", offset)) {
int end = text.indexOf('>', offset);
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
String body = text.substring(offset + "<SHiNE:".length(), end);
if (body.startsWith("title;")) {
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
seenTitle = true;
title = parseTitleTag(body);
} else if (body.startsWith("avatar;")) {
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
seenAvatar = true;
Avatar avatar = parseAvatarTag(body);
avaAr = avatar.ar;
avaSha256 = avatar.sha256;
avaSize = avatar.size;
} else {
throw new IllegalArgumentException("unknown_channel_meta_tag");
}
offset = end + 1;
if (offset < text.length() && text.charAt(offset) == '\n') offset += 1;
}
String description = text.substring(offset).trim();
if (countCodePoints(description) > MAX_DESCRIPTION_CHARS) {
throw new IllegalArgumentException("channel_meta_description_too_long");
}
String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim();
validateTitle(cleanTitle);
return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize);
}
private static String parseTitleTag(String body) {
String prefix = "title;v=1;";
if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title");
String title = body.substring(prefix.length()).trim();
validateTitle(title);
return title;
}
private static Avatar parseAvatarTag(String body) {
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
long size;
try {
size = Long.parseLong(sizeRaw);
} catch (Exception e) {
throw new IllegalArgumentException("bad_channel_meta_avatar_size");
}
if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size");
return new Avatar(ar, sha256, size);
}
private static Map<String, String> parseFields(String raw) {
Map<String, String> out = new HashMap<>();
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
int eq = part.indexOf('=');
if (eq <= 0) continue;
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
if (!key.isEmpty()) out.put(key, value);
}
return out;
}
private static void validateTitle(String title) {
String value = String.valueOf(title == null ? "" : title);
if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0
|| value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
|| value.indexOf('\0') >= 0) {
throw new IllegalArgumentException("bad_channel_meta_title_chars");
}
if (countCodePoints(value) > MAX_TITLE_CHARS) {
throw new IllegalArgumentException("channel_meta_title_too_long");
}
}
private static int countCodePoints(String value) {
return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length());
}
private record Avatar(String ar, String sha256, long size) {}
public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {}
}
@@ -1,7 +1,9 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.MsgSubType;
import blockchain.body.CreateChannelBody;
import blockchain.body.TextLineBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.DbController;
@@ -20,6 +22,7 @@ import java.util.Map;
public final class ChannelNamesStateBootstrapper {
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
private static final int MSG_TYPE_TECH = 0;
private static final int MSG_TYPE_TEXT = 1;
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
private static volatile boolean bootstrapped;
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
continue;
}
final String displayName;
String displayName;
final String slug;
final String channelDescription;
String channelDescription;
final int channelTypeCode;
final int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
try {
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
slug = ChannelNameRules.toCanonicalSlug(displayName);
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, displayName);
displayName = createMeta.title();
channelDescription = createMeta.description();
avaAr = createMeta.avaAr();
avaSha256 = createMeta.avaSha256();
avaSize = createMeta.avaSize();
metaUpdatedAtMs = parsed.timestamp * 1000L;
}
} catch (Exception badName) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
continue;
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
entry.setSlug(slug);
entry.setDisplayName(displayName);
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
entry.setAvaAr(avaAr);
entry.setAvaSha256(avaSha256);
entry.setAvaSize(avaSize);
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
entry.setOwnerLogin(ownerLogin);
entry.setOwnerBlockchainName(ownerBch);
entry.setChannelTypeCode(channelTypeCode);
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
dao.clearAll(c);
dao.insertAll(c, entries);
applyMetaUpdates(c, dao, entries, skipped);
c.commit();
log.info("channel_names_state bootstrapped: {}", entries.size());
if (!conflicts.isEmpty()) {
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
}
}
private static void applyMetaUpdates(Connection c,
ChannelNameStateDAO dao,
List<ChannelNameStateEntry> entries,
List<String> skipped) throws Exception {
Map<String, String> fallbackTitles = new LinkedHashMap<>();
for (ChannelNameStateEntry entry : entries) {
fallbackTitles.put(entry.getOwnerBlockchainName() + "#" + entry.getChannelRootBlockNumber(), entry.getSlug());
}
String sql = """
SELECT bch_name, block_number, block_bytes
FROM blocks
WHERE msg_type = ? AND msg_sub_type = ?
ORDER BY bch_name, block_number
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MSG_TYPE_TEXT);
ps.setInt(2, MsgSubType.TEXT_CHANNEL_META);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String ownerBch = rs.getString("bch_name");
int blockNumber = rs.getInt("block_number");
byte[] blockBytes = rs.getBytes("block_bytes");
try {
BchBlockEntry parsed = new BchBlockEntry(blockBytes);
if (!(parsed.body instanceof TextLineBody metaBody)) continue;
int lineCode = metaBody.lineCode();
String fallbackTitle = fallbackTitles.get(ownerBch + "#" + lineCode);
if (fallbackTitle == null) continue;
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, fallbackTitle);
ChannelNameStateEntry update = new ChannelNameStateEntry();
update.setOwnerBlockchainName(ownerBch);
update.setChannelRootBlockNumber(lineCode);
update.setDisplayName(parsedMeta.title());
update.setChannelDescription(parsedMeta.description());
update.setAvaAr(parsedMeta.avaAr());
update.setAvaSha256(parsedMeta.avaSha256());
update.setAvaSize(parsedMeta.avaSize());
update.setMetaUpdatedAtMs(parsed.timestamp * 1000L);
dao.updateMeta(c, update);
} catch (Exception e) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_meta)");
}
}
}
}
}
}
@@ -23,8 +23,6 @@ final class ChannelsReadSupport {
static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0;
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_REMOVE = "remove";
@@ -47,11 +45,40 @@ final class ChannelsReadSupport {
if (rootNumber == 0) {
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;
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";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
@@ -68,21 +95,29 @@ final class ChannelsReadSupport {
BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) {
meta.channelName = ccb.channelName;
meta.displayName = ccb.channelName;
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(meta.channelDescription, meta.channelName);
meta.displayName = parsedMeta.title();
meta.channelDescription = parsedMeta.description();
meta.avaAr = parsedMeta.avaAr();
meta.avaSha256 = parsedMeta.avaSha256();
meta.avaSize = parsedMeta.avaSize();
meta.metaUpdatedAtMs = e.timestamp * 1000L;
}
} else {
meta.channelName = null;
meta.displayName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
}
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (updatedDescription != null) {
meta.channelDescription = updatedDescription;
}
return meta;
} catch (Exception ignored) {
meta.channelName = null;
meta.displayName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
return meta;
@@ -106,7 +141,7 @@ final class ChannelsReadSupport {
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
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
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
ORDER BY block_number DESC
@@ -133,7 +168,7 @@ final class ChannelsReadSupport {
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
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
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
@@ -270,8 +305,7 @@ final class ChannelsReadSupport {
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
return fromCommand == null ? "" : fromCommand;
return "";
}
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
@@ -287,8 +321,7 @@ final class ChannelsReadSupport {
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
return fromCommand == null ? saved : fromCommand;
return saved;
}
}
} catch (SQLException ignored) {
@@ -306,8 +339,6 @@ final class ChannelsReadSupport {
BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (fromCommand != null) return fromCommand;
return ccb.channelDescription == null ? "" : ccb.channelDescription;
}
return "";
@@ -363,45 +394,97 @@ final class ChannelsReadSupport {
return meta.channelTypeVersion;
}
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT block_bytes
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
List<MetaEvent> out = new ArrayList<>();
if (lineCode <= 0) return out;
ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode);
String rootSql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
ORDER BY block_number DESC
LIMIT 300
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TECH);
ps.setInt(3, 1);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
MetaEvent event = new MetaEvent();
event.kind = "created";
event.login = rs.getString("login");
event.bchName = rs.getString("bch_name");
event.blockNumber = rs.getInt("block_number");
event.blockHash = rs.getBytes("block_hash");
event.lineStep = null;
try {
BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes"));
event.createdAtMs = entry.timestamp * 1000L;
if (entry.body instanceof CreateChannelBody ccb) {
event.title = ccb.channelName;
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
if (Short.toUnsignedInt(ccb.channelTypeCode) == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(event.description, ccb.channelName);
event.title = parsedMeta.title();
event.description = parsedMeta.description();
event.avaAr = parsedMeta.avaAr();
event.avaSha256 = parsedMeta.avaSha256();
event.avaSize = parsedMeta.avaSize();
}
}
} catch (Exception ignored) {
event.createdAtMs = 0L;
}
if (event.title == null || event.title.isBlank()) {
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
}
if (event.description == null) event.description = "";
if (event.avaAr == null) event.avaAr = "";
if (event.avaSha256 == null) event.avaSha256 = "";
out.add(event);
}
}
}
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, MsgSubType.TEXT_REPOST);
ps.setInt(5, lineCode);
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
CommandInfo commandInfo = parseCommandText(info.text);
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
return commandInfo.arg;
byte[] bytes = rs.getBytes("block_bytes");
TextInfo info = parseTextAndTime(bytes);
try {
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;
}
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);
return out;
}
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
@@ -526,19 +609,29 @@ final class ChannelsReadSupport {
static final class ChannelMeta {
String channelName;
String displayName;
String channelDescription;
int channelTypeCode;
int channelTypeVersion;
String avaAr = "";
String avaSha256 = "";
long avaSize = 0L;
long metaUpdatedAtMs = 0L;
}
static final class CommandInfo {
final String command;
final String arg;
CommandInfo(String command, String arg) {
this.command = command;
this.arg = arg;
}
static final class MetaEvent {
String kind;
String login;
String bchName;
int blockNumber;
byte[] blockHash;
Integer lineStep;
long createdAtMs;
String title;
String description;
String avaAr;
String avaSha256;
long avaSize;
}
static final class PairChannelSelector {
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
channel.setChannelName(meta.channelName);
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
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.setChannelTypeVersion(meta.channelTypeVersion);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setChannelRoot(rootRef);
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<>(
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
);
@@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(meta.channelName);
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
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.setChannelTypeVersion(meta.channelTypeVersion);
channelRef.setPersonal(meta.channelTypeCode == 100);
@@ -8,6 +8,7 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel;
private List<MessageItem> messages = new ArrayList<>();
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
public Channel getChannel() { return 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 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 {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private String displayName;
private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private BlockRef channelRoot;
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelName() { return 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 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 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 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 {
private BlockRef messageRef;
private Integer msgSubType;
@@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private String displayName;
private String channelDescription;
private String avaAr;
private String avaSha256;
private Long avaSize;
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private boolean personal;
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelName() { return 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 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 void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.3.1
server.version=1.3.1
client.version=1.5.1
server.version=1.4.5
+1
View File
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="server2shineupme" \
DEPLOY_SERVER_ADDRESS="server2.shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \
TARGET_URL="https://server2.shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="shineupme" \
DEPLOY_SERVER_ADDRESS="shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \
TARGET_URL="https://shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
+56 -4
View File
@@ -88,6 +88,7 @@
- `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
@@ -104,6 +105,7 @@
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
@@ -133,7 +135,57 @@
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
## 6. Хватает ли функций сейчас
## 6. Практические payload-форматы для каналов и вложений
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
### Вложения в сообщениях
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
Пример текстового содержимого body:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
Пример вложения с отдельным preview-файлом:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
### Создание публичного канала с профилем
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
```text
<SHiNE:title;v=1;Человекочитаемое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Описание канала
```
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
### Изменение профиля канала
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`.
Текстовое содержимое body использует тот же формат полного снимка профиля:
```text
<SHiNE:title;v=1;Новое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Новое описание канала
```
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
## 7. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
@@ -147,7 +199,7 @@
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков
## 8. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
@@ -157,7 +209,7 @@
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 8. USER_PARAM для «личных данных»
## 9. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**:
@@ -183,7 +235,7 @@
---
## 9. `GetBlockchainBlock`
## 10. `GetBlockchainBlock`
### Назначение
+40
View File
@@ -53,6 +53,12 @@
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelName": "0",
"displayName": "Мой канал",
"channelDescription": "Короткое описание",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
@@ -141,8 +147,42 @@
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "news",
"displayName": "Новости Bob",
"channelDescription": "Канал о новостях",
"avaAr": "ArweaveTxId...",
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"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": [
{
"messageRef": { "blockNumber": 140, "blockHash": "..." },
@@ -9,6 +9,23 @@ Payload включает:
4. `channelType` (`uint16`, 2 байта);
5. `channelTypeVersion` (`uint16`, 2 байта).
### `channelDescription`
`channelDescription` хранится как `uint16 length + UTF-8`, максимум `2048` байт.
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
Если `title` отсутствует, человекочитаемым именем считается техническое `channelName`.
Если `avatar` отсутствует, канал создаётся без аватара.
Новые UI-клиенты создают публичный канал одним блоком `TECH_CREATE_CHANNEL`; отдельный `TEXT_CHANNEL_META` при создании не пишется. `TEXT_CHANNEL_META` используется для последующих изменений профиля канала.
## 2. Типы каналов
- `0``stories` (root-канал пользователя).
- `1` — публичный канал.
+10 -24
View File
@@ -1,33 +1,19 @@
# Командные сообщения каналов
## 1. Общий префикс
Командные сообщения распознаются по префиксу:
Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала.
`/.`
## Удалённая команда `/.desc`
Пример:
- `/.desc Новый комментарий канала`
Команда `/.desc <text>` больше не поддерживается и не применяется сервером.
## 2. Поддерживаемые команды
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
### Для всех типов каналов (`0`, `1`, `100`, `200`)
- `/.desc <text>` — смена описания канала.
- `msg_type=1`
- `subType=70`
- `TEXT_CHANNEL_META`
Примечание:
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
### Дополнительно для `type=200`
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
## Зарезервированные команды type=200
Формат аргументов фиксирован: через пробел.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются.
+48
View File
@@ -27,6 +27,14 @@ TEXT-тип хранит сообщения и редактирования.
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
6. `subType=70``TEXT_CHANNEL_META`
- скрытый технический снимок профиля канала;
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
- не отображается как обычное сообщение ленты;
- применяется сервером к текущему состоянию канала.
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
@@ -36,3 +44,43 @@ TEXT-тип хранит сообщения и редактирования.
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
Подробная спецификация: [15_TEXT_Attachments.md](./15_TEXT_Attachments.md).
Бинарный формат `TextBody` не меняется: вложения являются частью UTF-8 текста.
Один блок вложения:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Для файлов с отдельным превью клиент может использовать расширенный вариант:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
```
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
Обязательные поля:
- `v=1` или `v=2`
- `name` - имя файла, закодированное через `encodeURIComponent`
- `size` - размер файла в байтах
- `sha256` - SHA-256 исходного файла в hex
- `ar` - короткий Arweave Transaction ID, без полного URL
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
Правила клиента:
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
+124
View File
@@ -0,0 +1,124 @@
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
## Область применения
Формат применяется к UTF-8 полю `text` в TEXT-блоках:
- `TEXT_POST`
- `TEXT_REPLY`
- `TEXT_EDIT_POST`
- `TEXT_EDIT_REPLY`
Бинарный формат `TextBody` не меняется. Вложения являются техническим префиксом внутри обычного текста.
## Общий вид
Один attach-блок:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Блок с превью:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Несколько вложений идут подряд в самом начале текста:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
## Поля
Обязательные поля:
- `v=1` - версия формата attach-блока;
- `name` - имя файла, закодированное через `encodeURIComponent`;
- `size` - размер файла в байтах;
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
Дополнительные поля для `v=2`:
- `previewAr` - короткий Arweave Transaction ID файла-превью;
- `previewSha256` - SHA-256 файла-превью в hex.
## Хранение файла
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
- имя файла;
- размер;
- SHA-256;
- Arweave `txId`.
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
## Создание вложения в UI
UI поддерживает три сценария:
- загрузить новый файл в Arweave выбранным Arweave-кошельком;
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла:
- основной видеофайл;
- отдельное изображение-превью.
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения.
Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением.
Если история очищена, уже созданные сообщения не меняются: в блокчейне остаются attach-блоки с `txId`.
## Отображение
Новый клиент при чтении сообщения:
1. читает валидные attach-блоки только из начала текста;
2. скрывает технические attach-строки;
3. отображает вложения над пользовательским текстом;
4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
5. определяет тип вложения по расширению имени файла:
- изображение показывает как ограниченное по размеру превью;
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
7. при перелистывании останавливает воспроизводящееся видео;
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
- если сообщение старше 20 минут: файл считается недоступным.
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
## Редактирование
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
## Совместимость и ограничения
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
+61
View File
@@ -0,0 +1,61 @@
# TEXT_CHANNEL_META: профиль канала
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала после создания.
## Блок
- `msg_type = 1`
- `subType = 70`
- `msgVersion = 1`
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
Первый снимок профиля публичного канала хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же текстовым форматом. Отдельный `TEXT_CHANNEL_META` при создании нового публичного канала UI не пишет.
## Текстовый формат
В начале текста могут идти технические теги, после них обычный текст описания:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
Поддерживаемые теги:
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
## Правила
- Каждый `TEXT_CHANNEL_META` задаёт полный снимок состояния профиля канала.
- Старые meta-блоки остаются в блокчейне как история, но текущее состояние берётся из последнего валидного meta-блока.
- Если `title` отсутствует, используется техническое имя канала.
- Если `avatar` отсутствует, аватар считается сброшенным.
- Если описание после тегов отсутствует, описание считается пустым.
- `meta_updated_at_ms` равен времени самого валидного meta-блока.
## Ограничения
- Длина `title` — максимум 50 Unicode code points.
- В `title` запрещены `<`, `>`, `;`, табуляция, перевод строки и `\0`.
- Длина описания — максимум 250 Unicode code points.
- `avatar.ar` — Arweave transaction id из 43 символов.
- `avatar.sha256` — 64 hex-символа.
- `avatar.size` — положительный размер файла в байтах.
Если meta-блок невалиден, сервер не применяет его целиком.
## UI
- При создании публичного канала UI записывает человекочитаемое имя, аватар и описание в `TECH_CREATE_CHANNEL.channelDescription`.
- При создании канала UI показывает системную карточку `Создан канал ...`.
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события.
## Замена старых команд
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
+72
View File
@@ -1,5 +1,56 @@
# История изменений документации блокчейна
## 2026-08-04 12:00:00 +0400
- Базовый коммит-ориентир: `391b18a`.
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением.
- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью.
## 2026-07-31 17:22:07 +0400
- Базовый коммит-ориентир: `05bd9f5`.
- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён.
## 2026-07-31 17:13:26 +0400
- Базовый коммит-ориентир: `60ab476`.
- Уточнён UI журнала загрузок Arweave: статус доступности перенесён в начало второй строки плитки, верхняя панель закреплена как fixed-полоса вне прокрутки, в меню добавлен пункт `Добавить файл`, меню закрывается при клике вне него; формат attach-блока не изменён.
## 2026-07-31 17:06:55 +0400
- Базовый коммит-ориентир: `bcae285`.
- Уточнён UI журнала загрузок Arweave: плитки принудительно не растягиваются по высоте, а контейнер журнала запрещает горизонтальную прокрутку; формат attach-блока не изменён.
## 2026-07-31 16:58:00 +0400
- Базовый коммит-ориентир: `cd67176`.
- Уточнён UI журнала загрузок Arweave: экран называется `Загрузка файлов`, верхняя панель стала компактной и закреплённой сверху, плитки журнала тёмные и уменьшены по высоте; формат attach-блока не изменён.
## 2026-07-31 13:48:57 +0400
- Базовый коммит-ориентир: `98eba54`.
- Уточнено UI-поведение журнала загрузок Arweave: экран называется `Загрузить файлы в блокчейн`, использует компактные плитки и локальный флаг `Не добавлен в SHiNE` для файлов, которые загружены заранее, но ещё не отправлены во вложении сообщения.
- Зафиксировано, что флаг хранится только в клиентском sessionStorage и снимается после успешной отправки сообщения с выбранным вложением; формат attach-блока не изменён.
## 2026-07-31 13:12:24 +0400
- Базовый коммит-ориентир: `c530627`.
- Для UI-вложений добавлен отдельный экран журнала загрузок Arweave в настройках: загрузка файла без создания сообщения, плитки истории с `txId`, размером, датой и статусом доступности через gateway.
- Уточнено, что журнал хранится только в `sessionStorage` текущего браузера и не влияет на уже записанные attach-блоки в блокчейне.
## 2026-07-31 12:47:41 +0400
- Базовый коммит-ориентир: `f961947`.
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
## 2026-07-30 11:12:47 +0400
- Базовый коммит-ориентир: `e9e6628`.
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
- В `docs/Blockchain/README.md` добавлена ссылка на документ вложений.
- В UI порядок отображения сообщения с вложениями закреплён как: вложения сверху, пользовательский текст снизу.
## 2026-07-30 10:31:26 +0400
- Базовый коммит-ориентир: `c7684d6`.
- Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксирован клиентский формат вложений `SHiNE:attach v=1`.
- Бинарный `TextBody` не изменён: вложения записываются техническими строками в начале UTF-8 `text`.
- Вложение хранит `name`, `size`, `sha256` и короткий Arweave `txId` в поле `ar`; имя файла кодируется через `encodeURIComponent`.
- В `docs/Blockchain/11_TEXT_Blocks.md` добавлены правила совместимости, отображения и допустимости пустого пользовательского текста при наличии вложений.
## 2026-07-14 20:43:43 +0400
- Базовый коммит-ориентир: `118be41`.
- В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта:
@@ -68,6 +119,27 @@
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
## 2026-08-01 01:45:29 +0400
- Базовый коммит-ориентир: `24afcba`.
- Добавлен новый `TEXT`-подтип `TEXT_CHANNEL_META (subType=70)` для скрытого технического снимка профиля канала.
- Зафиксирован формат тегов `<SHiNE:title;v=1;...>` и `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`; описание хранится хвостовым UTF-8 текстом.
- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром.
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`.
## 2026-08-01 02:18:00 +0400
- Базовый коммит-ориентир: `8125d1a`.
- Увеличен лимит `CreateChannelBody.channelDescription` до `2048` UTF-8 байт.
- Для публичных каналов начальный профиль канала теперь хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же meta-форматом, что `TEXT_CHANNEL_META`.
- UI создания публичного канала больше не пишет отдельный `TEXT_CHANNEL_META` сразу после создания; отдельный meta-блок используется только для последующих изменений профиля.
- Bootstrap `channel_names_state` при старте сервера теперь применяет последующие `TEXT_CHANNEL_META` поверх начального профиля из `TECH_CREATE_CHANNEL`.
## 2026-07-31 17:40:31 +0400
- Базовый коммит-ориентир: `d640dd6`.
- Формат блокчейна не менялся.
- В UI каналов добавлен технический просмотр данных сообщения из уже полученного ответа сервера: цепочка автора, номер записи, хэш, тип, время, текст, public key/подпись при наличии и сырой JSON-блок.
- Удалённые сообщения в UI теперь отображаются компактной строкой с переходом в историю версий.
## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
+14 -10
View File
@@ -3,25 +3,29 @@
Этот каталог описывает только текущий рабочий формат протокола для MVP.
## Основные документы
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
Системные блоки (`msg_type=0`).
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
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`.
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
11. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
10. [CHANGELOG.md](./CHANGELOG.md)
12. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Смежная документация
-2
View File
@@ -155,8 +155,6 @@
При переносе нужно отдельно учитывать:
- заголовок и стрелку назад;
- поля логина и пароля;
- переключатель режима 12 слов;
- сетку слов;
- строку статуса длины пароля;
- строку статуса проверки логина;
- кнопку проверки логина;
+11 -1
View File
@@ -9,7 +9,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260715214500';
window.__SHINE_BUILD_HASH__ = '20260806223040';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
@@ -26,6 +26,16 @@ window.__SHINE_BUILD_HASH__ = '20260715214500';
</script>
</head>
<body>
<div id="initial-splash" class="initial-splash" aria-label="Сияние">
<div class="initial-splash__logo-wrap">
<img
class="initial-splash__logo"
src="./img/shine-logo-transparent-final.png"
alt="Логотип Сияние"
/>
</div>
<div class="initial-splash__brand">Сияние</div>
</div>
<div class="app-shell">
<main id="app-screen" class="screen-content"></main>
<div id="toolbar-slot" class="toolbar-slot"></div>
+27
View File
@@ -59,6 +59,7 @@ import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
@@ -120,6 +121,7 @@ const routes = {
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
@@ -150,6 +152,7 @@ const routes = {
const screenEl = document.getElementById('app-screen');
const toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell');
const initialSplashEl = document.getElementById('initial-splash');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
@@ -175,6 +178,7 @@ let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = false;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
@@ -329,6 +333,11 @@ function refreshConnectionUi() {
const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return;
if (!initialConnectionCompleted) {
bannerEl.hidden = true;
return;
}
if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true;
stopConnectionCountdown();
@@ -358,6 +367,21 @@ function refreshConnectionUi() {
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
}
function finishInitialConnectionSplash() {
if (initialConnectionCompleted) return;
initialConnectionCompleted = true;
if (!initialSplashEl) {
refreshConnectionUi();
return;
}
initialSplashEl.classList.add('is-leaving');
const removeSplash = () => initialSplashEl.remove();
initialSplashEl.addEventListener('transitionend', removeSplash, { once: true });
window.setTimeout(removeSplash, 700);
refreshConnectionUi();
}
function startConnectionCountdown() {
if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => {
@@ -465,6 +489,9 @@ function setConnectionStatus(nextState, text = '') {
if (!state) return;
connectionState = state;
connectionStatusText = String(text || '').trim();
if (state === 'connected') {
finishInitialConnectionSplash();
}
if (state === 'disconnected') {
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
File diff suppressed because it is too large Load Diff
+28 -608
View File
@@ -1,56 +1,7 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
prepareAvatarImageFile,
sha256HexFromArrayBuffer,
uploadArweaveFile,
validateArweaveTxId,
validateSha256Hex,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function formatAr(ar) {
const n = Number(ar);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
}
function clearJwk(ctx) {
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
Object.keys(ctx.jwk).forEach((key) => {
ctx.jwk[key] = '';
});
ctx.jwk = null;
}
function setNodeText(node, text) {
if (node) node.textContent = String(text || '');
}
export function openAvatarWizard({
export async function openAvatarWizard({
login,
storagePwd,
gateway,
@@ -58,568 +9,37 @@ export function openAvatarWizard({
onAvatarSaved,
onStatus,
} = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || '').trim();
if (!cleanLogin || !cleanStoragePwd) {
return Promise.reject(new Error('Нет активной сессии.'));
throw new Error('Нет активной сессии.');
}
let closed = false;
let lastPreviewUrl = '';
let walletCtx = null;
let balanceInfo = null;
let optimized = null;
let priceInfo = null;
let uploadedTxId = '';
let uploadedSha256Hex = '';
let uploadedInfoText = '';
let freeQuotaInfo = null;
function revokePreviewUrl() {
if (!lastPreviewUrl) return;
URL.revokeObjectURL(lastPreviewUrl);
lastPreviewUrl = '';
}
function close(result = false, resolve) {
if (closed) return;
closed = true;
revokePreviewUrl();
clearJwk(walletCtx);
walletCtx = null;
root.innerHTML = '';
resolve(result);
}
async function ensurePreviewImage(url, imageEl) {
return new Promise((resolve, reject) => {
const img = imageEl;
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
img.src = url;
});
}
return new Promise((resolve) => {
const showStepChoice = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
<div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
};
const showStepExistingInput = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="next">Далее</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const inputEl = root.querySelector('#avatar-existing-txid');
const errorEl = root.querySelector('[data-error="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
const txId = String(inputEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
setNodeText(errorEl, '');
void showStepExistingPreview(txId);
});
window.setTimeout(() => inputEl?.focus(), 0);
};
const showStepExistingPreview = async (txId) => {
if (closed) return;
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
let existingSha256Hex = '';
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Предпросмотр аватара</h3>
<div class="avatar-preview-circle avatar-wizard-preview">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const imageEl = root.querySelector('[data-preview-image="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const saveBtn = root.querySelector('[data-action="save"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
try {
await ensurePreviewImage(previewUrl, imageEl);
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
}
saveBtn?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
};
const showStepZeroBalance = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Недостаточно средств</h3>
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
<div class="avatar-wizard-actions">
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
close(false, resolve);
if (typeof navigate === 'function') navigate('wallet-view');
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUploadForm = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
let selectedFile = null;
optimized = null;
priceInfo = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
priceInfo = await getArweaveUploadPrice({
gateway: cleanGateway,
byteLength: optimized.file.size,
});
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
`;
if (!hasFunds) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} catch (error) {
const message = String(error?.message || '');
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
setNodeText(errorEl, message);
} else if (message.includes('цену загрузки')) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
} else {
setNodeText(errorEl, 'Не удалось подготовить изображение.');
}
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
return;
}
uploadBtn.disabled = true;
try {
uploadedInfoText = '';
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
],
});
uploadedTxId = String(uploaded.id || '').trim();
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
if (!uploadedTxId) {
throw new Error('Пустой Transaction ID');
}
if (!validateSha256Hex(uploadedSha256Hex)) {
throw new Error('Некорректный SHA256');
}
showStepUploaded();
} catch {
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
uploadBtn.disabled = false;
}
});
};
const showStepFreeLimitExhausted = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Лимит исчерпан</h3>
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepFreeUploadForm = () => {
if (closed) return;
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
const limit = Number(freeQuotaInfo?.limit || 3);
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Залить аватар бесплатно</h3>
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-free-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
optimized = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
const selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
if (Number(optimized?.file?.size || 0) > maxBytes) {
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
}
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
`;
uploadBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
uploadBtn.disabled = true;
try {
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
const uploaded = await authService.uploadTestFreeAvatar({
contentType: optimized.contentType,
fileBytesBase64: bytesToBase64(fileBytes),
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
});
uploadedTxId = String(uploaded.txId || '').trim();
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
if (!uploadedTxId) {
throw new Error('Сервер не вернул Transaction ID.');
}
showStepUploaded();
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
uploadBtn.disabled = false;
}
});
};
const showStepFreeUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
} catch (error) {
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
return;
}
if (!freeQuotaInfo?.enabled) {
setNodeText(loadingEl, '');
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
return;
}
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
showStepFreeLimitExhausted();
return;
}
showStepFreeUploadForm();
};
const showStepUploaded = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Файл загружен в Arweave</h3>
<p class="meta-muted">Transaction ID:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
</div>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
`;
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(uploadedTxId);
setNodeText(errorEl, '');
} catch {
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
}
});
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка загрузки</h3>
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
walletCtx = await getArweaveWalletFromStoredClientKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
onStatus: (message) => {
setNodeText(loadingEl, message);
if (typeof onStatus === 'function') onStatus(message);
},
});
balanceInfo = await getArweaveBalance({
gateway: cleanGateway,
address: walletCtx.address,
});
} catch (error) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
return;
}
if (BigInt(balanceInfo.winston) <= 0n) {
showStepZeroBalance();
return;
}
showStepUploadForm();
};
showStepChoice();
const item = await openArweaveAttachmentManager({
login: cleanLogin,
storagePwd: cleanStoragePwd,
gateway: cleanGateway,
navigate,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (!item?.ar || !item?.sha256) return false;
if (typeof onStatus === 'function') {
onStatus('Сохраняем аватар в профиль...');
}
await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256);
if (typeof onAvatarSaved === 'function') {
await onAvatarSaved(item);
}
if (typeof onStatus === 'function') {
onStatus('');
}
return true;
}
+71 -15
View File
@@ -3,10 +3,11 @@ import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDescription,
normalizeChannelDisplayName,
validateChannelDisplayName,
} 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: 'Создать канал' };
@@ -21,13 +22,30 @@ function persistCreateSuccessFlash(message) {
}
}
function validateDescription(value) {
const normalized = normalizeChannelDescription(value);
const bytes = new TextEncoder().encode(normalized).length;
if (bytes > 200) {
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
function normalizeMetaText(value, max, label) {
const normalized = String(value || '').trim();
const length = Array.from(normalized).length;
if (length > max) {
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
}
return { ok: true, normalized, bytes, error: '' };
return { ok: true, normalized, length, error: '' };
}
function renderAvatarPreview(slot, avatar, title) {
if (!slot) return;
slot.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap);
}
export function render({ navigate }) {
@@ -50,13 +68,21 @@ export function render({ navigate }) {
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
<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 />
<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>
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
<div id="channel-description-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 titleEl = form.querySelector('#channel-title');
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 titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter');
const descriptionErrorEl = form.querySelector('#channel-description-error');
const descriptionCounterEl = form.querySelector('#channel-description-counter');
const errorEl = form.querySelector('#channel-create-error');
@@ -76,38 +107,60 @@ export function render({ navigate }) {
const cancelEl = form.querySelector('#cancel-create-channel');
let submitInFlight = false;
let selectedAvatar = null;
const setBusy = (busy) => {
submitInFlight = !!busy;
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight;
titleEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight;
avatarBtn.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
};
const updateValidation = () => {
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);
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
const descLength = Number(descriptionCheck.bytes || 0);
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
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;
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized);
return {
ok,
name: nameCheck.normalized,
title: titleCheck.normalized,
description: descriptionCheck.normalized,
};
};
nameEl.addEventListener('input', updateValidation);
titleEl.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) => {
event.preventDefault();
@@ -131,10 +184,13 @@ export function render({ navigate }) {
login,
storagePwd,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description),
channelDescription: check.description,
channelProfileTitle: check.title,
channelAvatar: selectedAvatar,
channelType: CHANNEL_TYPE_PUBLIC,
channelTypeVersion: 1,
});
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
navigate('channels-list');
+173
View File
@@ -0,0 +1,173 @@
import {
clearArweaveAttachmentHistory,
formatArweaveHistoryTime,
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
function closeUploadsMenu(controls) {
const menu = controls?.querySelector?.('[data-menu="true"]');
if (menu) menu.hidden = true;
}
function setStatusBadge(node, status, label) {
if (!node) return;
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
node.textContent = label || 'Проверяем...';
}
function renderTile(item, index) {
const tile = document.createElement('article');
tile.className = `ar-attachment-history-tile ar-attachment-history-tile--page${item?.preview?.ar ? ' ar-attachment-history-tile--with-preview' : ''}`;
const name = document.createElement('strong');
name.className = 'ar-attachment-history-name';
name.textContent = item.name;
const status = document.createElement('span');
status.className = 'ar-attachment-status ar-attachment-status--pending';
status.dataset.statusIndex = String(index);
status.textContent = 'Проверяем...';
const meta = document.createElement('div');
meta.className = 'ar-attachment-history-meta-row';
const size = document.createElement('span');
size.textContent = formatBytes(item.size);
const time = document.createElement('span');
time.textContent = formatArweaveHistoryTime(item.uploadedAtMs);
meta.append(status, size, time);
if (item?.preview?.ar) {
const previewFlag = document.createElement('span');
previewFlag.className = 'ar-attachment-placement-flag';
previewFlag.textContent = 'С превью';
meta.append(previewFlag);
}
if (item.pendingPlacement) {
const flag = document.createElement('span');
flag.className = 'ar-attachment-placement-flag';
flag.textContent = 'Не добавлен в SHiNE';
meta.append(flag);
}
const txId = document.createElement('div');
txId.className = 'mono-cell ar-attachment-history-txid';
txId.textContent = item.ar;
tile.append(name, meta, txId);
return tile;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack arweave-uploads-screen';
const controls = document.createElement('div');
controls.className = 'arweave-uploads-toolbar';
controls.innerHTML = `
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад"></button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
<button class="text-btn" type="button" data-action="help">Справка</button>
</div>
`;
const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error';
const list = document.createElement('div');
list.className = 'ar-attachment-history-tiles arweave-uploads-list';
const uploadFile = async () => {
statusLine.textContent = '';
closeUploadsMenu(controls);
try {
await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: true,
});
renderHistory();
} catch (error) {
statusLine.textContent = toUserMessage(error, 'Не удалось загрузить файл.');
}
};
function renderHistory() {
const rows = readArweaveAttachmentHistory(state.session.login).slice().reverse();
list.innerHTML = '';
statusLine.textContent = '';
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
list.append(empty);
return;
}
rows.forEach((item, index) => {
list.append(renderTile(item, index));
const statusEl = list.querySelector(`[data-status-index="${index}"]`);
void getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: item,
})
.then((result) => setStatusBadge(statusEl, result.status, result.label))
.catch(() => setStatusBadge(statusEl, 'unavailable', 'Недоступен'));
});
window.setTimeout(() => {
list.scrollTop = list.scrollHeight;
}, 0);
}
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
event.stopPropagation();
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = !menu.hidden;
});
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
});
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
);
});
screen.addEventListener('click', (event) => {
if (controls.contains(event.target)) return;
closeUploadsMenu(controls);
});
screen.append(controls, statusLine, list);
renderHistory();
return screen;
}
+202 -11
View File
@@ -12,6 +12,14 @@ import {
} from '../services/channels-ux.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
@@ -322,6 +330,91 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
function setActionTitle(button, label) {
if (!button) return;
button.title = label;
button.setAttribute('aria-label', label);
const labelEl = button.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = label;
}
async function copyTextToClipboard(text) {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
authorLogin,
authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '',
blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
blockHash: target?.blockHash || source?.messageRef?.blockHash || '',
localNumber,
msgSubType: msgSubType ?? source.msgSubType ?? '',
createdAtMs: timestampMs || source.createdAtMs || '',
text: String(text || ''),
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
raw,
};
}
function openBlockchainDetailsModal(details) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
<label class="field-label" for="thread-blockchain-details-text">Текст записи</label>
<textarea class="input" id="thread-blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
<pre class="blockchain-raw-block" id="thread-blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-blockchain-details-copy" type="button">Скопировать</button>
<button class="secondary-btn" id="thread-blockchain-details-raw" type="button">Показать сырой блок</button>
</div>
<button class="secondary-btn" id="thread-blockchain-details-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
showToast('Данные блокчейна скопированы');
});
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
const rawEl = root.querySelector('#thread-blockchain-raw-block');
if (!rawEl) return;
rawEl.hidden = !rawEl.hidden;
});
}
function resolveNodeText(node) {
return firstNonEmptyText(
node?.text,
@@ -331,6 +424,25 @@ function resolveNodeText(node) {
);
}
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
@@ -338,6 +450,8 @@ function openReplyModal({ onSubmit, navigate }) {
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
@@ -348,14 +462,17 @@ function openReplyModal({ onSubmit, navigate }) {
`;
const textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -368,8 +485,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
return;
}
@@ -377,7 +494,8 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(text);
await onSubmit(composeMessageWithAttachments(text, attachments));
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
setBusy(false);
@@ -385,6 +503,26 @@ function openReplyModal({ onSubmit, navigate }) {
}
});
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
if (textEl) textEl.focus();
@@ -504,7 +642,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -531,7 +669,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
@@ -569,6 +707,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const msgSubType = Number(node?.msgSubType || 0);
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
const parsedText = parseMessageAttachments(text);
const headingText = String(heading || '').trim();
if (headingText) {
@@ -618,11 +757,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
authorTile.append(avatar, authorBlock);
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
card.append(authorTile, body);
if (isDeletedMessage) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${author}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
event.stopPropagation();
openMessageHistoryModal({
title: `История #${localNumber}`,
versions,
});
});
card.append(deleted);
return card;
} else {
card.append(authorTile);
if (parsedText.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: node?.createdAtMs,
}));
}
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = parsedText.text;
card.append(body);
}
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
@@ -649,6 +813,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -660,6 +825,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
setActionTitle(likeButton, 'Лайк...');
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@@ -680,6 +846,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -696,6 +863,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -713,6 +881,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span>
`;
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
@@ -727,6 +896,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
actions.append(originalButton);
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Данные блокчейна</span>
`;
setActionTitle(detailsButton, 'Данные блокчейна');
detailsButton.addEventListener('click', (event) => {
event.stopPropagation();
openBlockchainDetailsModal(buildBlockchainDetails({
target,
authorLogin: author,
timestampMs: node?.createdAtMs,
text,
raw: node,
localNumber,
msgSubType,
}));
});
actions.append(detailsButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -740,8 +930,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(text || '').trim() === 'удалено' ? '' : text,
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
});
});
+465 -30
View File
@@ -18,12 +18,21 @@ import {
} from '../services/channels-ux.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
} from '../services/shine-routes.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_STORIES = 0;
@@ -278,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');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
<div class="modal-card stack">
<h3 class="modal-title">О канале</h3>
<p><strong>${channel.displayName || channel.name}</strong></p>
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
<div class="modal-card stack channel-profile-card">
<div class="channel-profile-modal-head">
<h3 class="modal-title">${escapeHtml(title)}</h3>
${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>
</div>
</div>
`;
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
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) {
@@ -306,6 +437,110 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
function setActionTitle(button, label) {
if (!button) return;
button.title = label;
button.setAttribute('aria-label', label);
const labelEl = button.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = label;
}
async function copyTextToClipboard(text) {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
authorLogin,
authorBlockchainName: messageRef?.blockchainName || source.authorBlockchainName || '',
blockNumber: messageRef?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
blockHash: messageRef?.blockHash || source?.messageRef?.blockHash || '',
localNumber,
msgSubType: msgSubType ?? source.msgSubType ?? '',
createdAtMs: timestampMs || source.createdAtMs || '',
text: String(text || ''),
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
raw,
};
}
function openBlockchainDetailsModal(details) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
<div class="modal" id="blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
<label class="field-label" for="blockchain-details-text">Текст записи</label>
<textarea class="input" id="blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
<pre class="blockchain-raw-block" id="blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
<div class="form-actions-grid">
<button class="secondary-btn" id="blockchain-details-copy" type="button">Скопировать</button>
<button class="secondary-btn" id="blockchain-details-raw" type="button">Показать сырой блок</button>
</div>
<button class="secondary-btn" id="blockchain-details-close" type="button">Закрыть</button>
</div>
</div>
`;
root.querySelector('#blockchain-details-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
showToast('Данные блокчейна скопированы');
});
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
const rawEl = root.querySelector('#blockchain-raw-block');
if (!rawEl) return;
rawEl.hidden = !rawEl.hidden;
});
}
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
});
container.append(button);
});
}
function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
@@ -313,6 +548,8 @@ function openReplyModal({ onSubmit, navigate }) {
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
@@ -323,14 +560,17 @@ function openReplyModal({ onSubmit, navigate }) {
`;
const textEl = root.querySelector('#reply-text');
const attachmentsEl = root.querySelector('#reply-attachments');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -343,8 +583,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
return;
}
@@ -352,7 +592,8 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(text);
await onSubmit(composeMessageWithAttachments(text, attachments));
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
setBusy(false);
@@ -360,6 +601,26 @@ function openReplyModal({ onSubmit, navigate }) {
}
});
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
@@ -447,6 +708,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="draft-attachments" id="channel-message-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение"> 📎</button>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
@@ -457,14 +720,17 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
`;
const textEl = root.querySelector('#channel-message-text');
const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -477,8 +743,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
if (!body) {
errorEl.textContent = 'Введите текст сообщения.';
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
}
@@ -486,7 +752,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(body);
await onSubmit(composeMessageWithAttachments(body, attachments));
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
setBusy(false);
@@ -494,6 +761,26 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
}
});
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
@@ -535,7 +822,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
@@ -563,7 +850,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
@@ -615,6 +902,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
repliesCount: Number(message?.repliesCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
rawMessage: message,
msgSubType: Number(message?.msgSubType || 0),
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
@@ -776,11 +1064,17 @@ async function loadFromApi(route, channelId) {
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
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 || 'неизвестно',
},
posts,
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
reverseChannelMissingWarning,
isOwnChannel,
isSubscribed,
@@ -868,6 +1162,48 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
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, {
navigate,
selector,
@@ -932,11 +1268,37 @@ function renderPostCard(post, {
});
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
const parsedBody = parseMessageAttachments(post.body);
card.append(authorTile, body);
if (isDeletedMessage) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
event.stopPropagation();
openMessageHistoryModal({
title: `История #${post.localNumber}`,
versions: post.versions,
});
});
card.append(deleted);
return card;
} else {
card.append(authorTile);
if (parsedBody.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: post.timestampMs,
}));
}
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = parsedBody.text;
card.append(body);
}
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@@ -962,6 +1324,7 @@ function renderPostCard(post, {
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -973,8 +1336,7 @@ function renderPostCard(post, {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Лайк...';
setActionTitle(likeButton, 'Лайк...');
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@@ -986,6 +1348,7 @@ function renderPostCard(post, {
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -1005,6 +1368,7 @@ function renderPostCard(post, {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
`;
setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -1021,6 +1385,7 @@ function renderPostCard(post, {
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span>
`;
setActionTitle(originalBtn, 'Оригинал');
originalBtn.addEventListener('click', (event) => {
event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
@@ -1035,6 +1400,27 @@ function renderPostCard(post, {
});
actions.append(originalBtn);
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Данные блокчейна</span>
`;
setActionTitle(detailsButton, 'Данные блокчейна');
detailsButton.addEventListener('click', (event) => {
event.stopPropagation();
openBlockchainDetailsModal(buildBlockchainDetails({
messageRef: post.messageRef,
authorLogin: post.authorLogin,
timestampMs: post.timestampMs,
text: post.body,
raw: post.rawMessage,
localNumber: post.localNumber,
msgSubType: post.msgSubType,
}));
});
actions.append(detailsButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -1048,8 +1434,9 @@ function renderPostCard(post, {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
allowEmptyText: parsedBody.attachments.length > 0,
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
@@ -1087,10 +1474,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
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) {
channelData.posts.forEach((post) => {
const row = renderPostCard(post, {
if (feedItems.length) {
feedItems.forEach((item) => {
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
return;
}
const row = renderPostCard(item.post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
@@ -1099,9 +1510,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onShare: handlers.onShare,
onEdit: handlers.onEdit,
});
const key = messageRefKey(post.messageRef);
const key = messageRefKey(item.post.messageRef);
if (key) {
postsByKey.set(key, post);
postsByKey.set(key, item.post);
}
feed.append(row);
});
@@ -1349,6 +1760,25 @@ export function render({ navigate, route }) {
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(statusBox);
@@ -1360,14 +1790,19 @@ export function render({ navigate, route }) {
try {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'} / ${apiData?.channel?.name || 'channel'}`;
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
if (channelHeaderButton) {
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
channelHeaderButton.textContent = titleLabel;
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel);
openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true,
onEdit: () => openEditChannelModal({
channel: apiData.channel,
onSave: onEditChannelMeta,
}),
});
};
}
skeleton.remove();
+30 -7
View File
@@ -1,6 +1,7 @@
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js';
import {
animatePress,
createSkeletonCard,
@@ -12,6 +13,7 @@ import {
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -20,6 +22,11 @@ const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
return String(parsed.text || '').trim() || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
}
function isChannelsDemoMode() {
try {
const qs = new URLSearchParams(window.location.search);
@@ -610,7 +617,7 @@ function mapMockGroups() {
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id),
}),
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
messagePreview: cleanChannelMessagePreview(channel.lastMessage),
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
notificationsEnabled: false,
@@ -646,11 +653,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
const channelName = summary?.channel?.channelName || '(без названия)';
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
const isOwn = bucketKey === 'own';
const title = `${ownerLogin} / ${channelName}`;
const title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`;
return {
id: rowId,
@@ -659,13 +668,16 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
avatar: avatarLetterFromName(channelName),
avatar: avatarLetterFromName(displayTitle || channelName),
avaAr: String(summary?.channel?.avaAr || '').trim(),
title,
technicalLabel,
channelName,
displayTitle,
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
@@ -962,6 +974,10 @@ function renderChannelMain(channel) {
title.className = 'channel-row-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) {
const desc = document.createElement('p');
desc.className = 'channel-row-description';
@@ -977,7 +993,7 @@ function renderChannelMain(channel) {
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(title);
main.prepend(title, technical);
main.append(preview, meta);
return main;
}
@@ -1006,7 +1022,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const avatar = document.createElement('div');
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);
@@ -1016,7 +1039,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
const count = document.createElement('span');
count.className = 'unread channel-row-count';
+25 -257
View File
@@ -7,31 +7,13 @@ import {
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { sha256Text } from '../services/crypto-utils.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
// ВРЕМЕННАЯ UI-ЗАГЛУШКА:
// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain,
// UI пускает логины 5..7 символов только по временному коду.
const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8;
const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5;
const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
function bytesToHex(bytes) {
return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join('');
}
async function isTemporaryPromoAccepted(value) {
const digest = await sha256Text(String(value || ''));
return bytesToHex(digest) === TEMP_PROMO_HASH_HEX;
}
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
function normalizeLoginForTemporaryUiGuard(login) {
const source = String(login || '').trim();
@@ -46,48 +28,6 @@ function normalizeLoginForTemporaryUiGuard(login) {
return normalized;
}
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
function makePasswordToggleIcons() {
return {
eye: `
@@ -115,10 +55,11 @@ export function render({ navigate }) {
const form = document.createElement('div');
form.className = 'card stack registration-form';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked);
let usePromoCode = Boolean(state.registrationDraft.usePromoCode);
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
let loginCheckTimer = 0;
let loginCheckRunId = 0;
@@ -155,67 +96,6 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton);
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
passwordInput.value = composePasswordFromWords(passwordWords);
passwordWordsLinked = true;
syncDraftState();
updateWordsPreview();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const promoToggle = document.createElement('label');
promoToggle.className = 'registration-toggle';
const promoCheckbox = document.createElement('input');
promoCheckbox.type = 'checkbox';
promoCheckbox.checked = usePromoCode;
const promoToggleLabel = document.createElement('span');
promoToggleLabel.textContent = 'У меня есть промокод';
promoToggle.append(promoCheckbox, promoToggleLabel);
const promoField = document.createElement('label');
promoField.className = 'stack';
const promoFieldLabel = document.createElement('span');
promoFieldLabel.className = 'field-label';
promoFieldLabel.textContent = 'Промокод';
const promoInput = document.createElement('input');
promoInput.className = 'input';
promoInput.type = 'text';
promoInput.autocomplete = 'off';
promoInput.autocapitalize = 'off';
promoInput.spellcheck = false;
promoInput.value = String(state.registrationDraft.promoCode || '');
promoInput.placeholder = 'Вставьте промокод';
const promoHint = document.createElement('p');
promoHint.className = 'meta-muted';
promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.';
promoField.append(promoFieldLabel, promoInput, promoHint);
const statusText = document.createElement('p');
statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none';
@@ -239,7 +119,6 @@ export function render({ navigate }) {
nextButton.type = 'button';
nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'password-length-hint';
let lastCheckedLogin = '';
@@ -251,13 +130,8 @@ export function render({ navigate }) {
return String(passwordInput.value || '');
}
function updateWordsPreview() {
const password = passwordMode === 'words'
? composePasswordFromWords(passwordWords)
: String(passwordInput.value || '');
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
function updatePasswordLength() {
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
}
function setStatusMessage(message, kind = '') {
@@ -295,32 +169,14 @@ export function render({ navigate }) {
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль');
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = 'grid';
passwordInputRow.style.display = 'grid';
updateWordsPreview();
}
function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.password = getCurrentPassword();
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = String(promoInput.value || '').trim();
}
function updatePromoVisibility() {
promoField.style.display = usePromoCode ? 'grid' : 'none';
}
async function runAvailabilityCheck({ automatic = false } = {}) {
const runId = ++loginCheckRunId;
const login = loginInput.value.trim();
const promoCode = String(promoInput.value || '').trim();
if (!login) {
setStatusMessage(automatic ? '' : 'Введите логин');
formError.style.display = 'none';
@@ -334,28 +190,8 @@ export function render({ navigate }) {
return false;
}
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) {
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO;
let promoAccepted = false;
if (usePromoCode) {
if (!promoCode) {
setStatusMessage('Введите промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
promoAccepted = await isTemporaryPromoAccepted(promoCode);
if (!promoAccepted) {
setStatusMessage('Неверный промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
} else if (wantsShortLogin) {
setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable');
if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
@@ -363,8 +199,6 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') {
@@ -375,7 +209,7 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
formError.style.display = 'none';
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo');
return lastCheckedFree && lastCheckedClassName === 'free';
}
setStatusMessage('Проверяем логин...');
@@ -388,19 +222,15 @@ export function render({ navigate }) {
const isFree = !check.exists;
let className = '';
if (isFree) {
if (promoAccepted) {
className = 'promo';
} else {
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
}
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
}
}
lastCheckedLogin = login;
@@ -408,8 +238,6 @@ export function render({ navigate }) {
lastCheckedClassName = className;
if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') {
@@ -420,7 +248,7 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
formError.style.display = 'none';
return isFree && (className === 'free' || className === 'promo');
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
const base = toUserMessage(error, 'Не удалось проверить логин');
@@ -438,65 +266,12 @@ export function render({ navigate }) {
});
passwordInput.addEventListener('input', () => {
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
passwordWords = emptyPasswordWords();
passwordWordsLinked = false;
wordInputs.forEach((input) => {
input.value = '';
});
}
syncDraftState();
updateWordsPreview();
});
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
if (!passwordWordsLinked) {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
} else if (passwordWordsLinked) {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState();
});
promoCheckbox.addEventListener('change', () => {
usePromoCode = promoCheckbox.checked;
resetLoginCheckState();
updatePromoVisibility();
syncDraftState();
if (usePromoCode) {
setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available');
} else {
setStatusMessage('');
scheduleAvailabilityCheck();
}
});
promoInput.addEventListener('input', () => {
resetLoginCheckState();
syncDraftState();
if (usePromoCode) scheduleAvailabilityCheck();
updatePasswordLength();
});
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const promoCode = String(promoInput.value || '').trim();
if (usePromoCode && !promoCode) {
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
formError.style.display = '';
return;
}
const isFree = await runAvailabilityCheck();
if (!isFree) return;
@@ -518,11 +293,6 @@ export function render({ navigate }) {
state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = promoCode;
if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null;
}
@@ -539,14 +309,12 @@ export function render({ navigate }) {
passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel);
passwordField = passwordLabel;
loginField.append(loginInput);
passwordField.append(passwordInputRow);
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton);
passwordLabel.append(passwordInputRow);
form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = '';
actions.append(nextButton);
updatePasswordModeVisibility();
updatePromoVisibility();
updatePasswordLength();
syncDraftState();
}
+1 -11
View File
@@ -62,17 +62,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
],
},
{
id: 'one-or-twelve',
shortTitle: '1 или 12 слов',
title: 'Чем отличается один пароль от режима 12 слов?',
paragraphs: [
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
'Используйте уникальный пароль, который не применяется в других сервисах.',
],
},
{
+7
View File
@@ -54,6 +54,12 @@ export function render({ navigate }) {
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузить файлы в блокчейн</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
@@ -62,6 +68,7 @@ export function render({ navigate }) {
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
+3
View File
@@ -31,6 +31,7 @@ const PRETTY_PATHS = new Map([
['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'],
@@ -261,6 +262,7 @@ export function getRoute() {
const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} };
@@ -373,6 +375,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
+39 -9
View File
@@ -1,6 +1,7 @@
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const WINSTON_PER_AR = 1_000_000_000_000n;
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -268,12 +269,42 @@ export async function prepareAvatarImageFile(file) {
}
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
export async function loadArweaveFileMetadata({ gateway, txId, maxBytes = MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES } = {}) {
const url = buildArweaveDataUrl({ gateway, txId });
const limit = Math.max(1, Number(maxBytes || MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES));
const response = await fetch(url, { method: 'GET', cache: 'no-store' });
if (!response.ok) {
throw new Error(`Не удалось скачать файл Arweave (${response.status} ${response.statusText})`);
}
const lengthHeader = String(response.headers.get('content-length') || '').trim();
const declaredSize = Number(lengthHeader);
if (Number.isFinite(declaredSize) && declaredSize > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const cleanType = String(response.headers.get('content-type') || '').split(';')[0].trim();
const sha256Hex = await sha256HexFromArrayBuffer(buffer);
return {
txId: String(txId || '').trim(),
sizeBytes: buffer.byteLength,
sha256Hex,
contentType: cleanType,
gateway: normalizeGateway(gateway),
};
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [], shineType = 'avatar' }) {
if (!jwk || typeof jwk !== 'object') {
throw new Error('Arweave-кошелёк не инициализирован.');
}
if (!(file instanceof File)) {
throw new Error('Выберите файл изображения.');
throw new Error('Выберите файл.');
}
const normalizedGateway = normalizeGateway(gateway);
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
const tx = await arweave.createTransaction({ data }, jwk);
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar');
tx.addTag('SHiNE-Type', String(shineType || 'file'));
const extraTags = Array.isArray(tags) ? tags : [];
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
const profileLogin = String(profileLoginTag?.value || '').trim();
if (!profileLogin) {
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
}
tx.addTag('SHiNE-Profile-Login', profileLogin);
extraTags.forEach((item) => {
const name = String(item?.name || '').trim();
const value = String(item?.value || '').trim();
if (name && value) tx.addTag(name, value);
});
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);
@@ -93,6 +93,53 @@ function pickCachedWallet(secrets) {
};
}
function normalizeExtraWallets(secrets) {
const rows = Array.isArray(secrets?.arweaveWallets) ? secrets.arweaveWallets : [];
return rows
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || item.address || '').trim(),
label: String(item.label || '').trim(),
address: String(item.address || '').trim(),
owner: String(item.owner || '').trim(),
jwk: item.jwk,
createdAtMs: Number(item.createdAtMs || 0),
}))
.filter((item) => item.id && item.address && item.jwk && typeof item.jwk === 'object');
}
function parseJwkSecretString(secret) {
const raw = String(secret || '').trim();
if (!raw) throw new Error('Введите секрет Arweave-кошелька.');
const tryParse = (value) => {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Некорректный JWK Arweave.');
}
return parsed;
};
if (raw.startsWith('{')) return tryParse(raw);
const normalized = raw
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(raw.length / 4) * 4, '=');
try {
return tryParse(atob(normalized));
} catch {
throw new Error('Секрет должен быть JSON JWK или base64url(JSON JWK).');
}
}
function validateArweaveJwk(jwk) {
if (!jwk || typeof jwk !== 'object') throw new Error('Некорректный JWK Arweave.');
['kty', 'e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi'].forEach((key) => {
if (!String(jwk[key] || '').trim()) throw new Error(`В JWK Arweave нет поля ${key}.`);
});
}
function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return;
try {
@@ -102,6 +149,63 @@ function safeStatus(onStatus, text) {
}
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
isDefault: true,
},
...extras.map((item) => ({
...item,
label: item.label || `Arweave ${item.address.slice(0, 6)}...${item.address.slice(-6)}`,
isDefault: false,
})),
];
}
export async function addArweaveWalletSecret({ login, storagePwd, secret, label = '' } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии.');
const jwk = parseJwkSecretString(secret);
validateArweaveJwk(jwk);
const moduleRef = await loadArweaveLib();
const Arweave = moduleRef?.default || moduleRef;
const arweave = Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' });
const address = await arweave.wallets.jwkToAddress(jwk);
const owner = String(jwk.n || '').trim();
const id = `manual-${address}`;
const createdAtMs = Date.now();
const wallet = {
id,
label: String(label || '').trim() || `Arweave ${address.slice(0, 6)}...${address.slice(-6)}`,
address,
owner,
jwk,
createdAtMs,
};
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (current) => {
const wallets = normalizeExtraWallets(current).filter((item) => item.address !== address);
wallets.push(wallet);
return {
...current,
arweaveWallets: wallets,
};
});
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
+403
View File
@@ -0,0 +1,403 @@
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
export const MAX_MESSAGE_ATTACHMENTS = 10;
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']);
export function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function normalizeName(name) {
const clean = String(name || 'file')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/]/g, '_')
.trim();
return clean || 'file';
}
function normalizePreview(input = {}) {
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
if (!previewTxId || !previewSha256Hex) return null;
if (!validateArweaveTxId(previewTxId)) return null;
if (!validateSha256Hex(previewSha256Hex)) return null;
return {
ar: previewTxId,
sha256: previewSha256Hex,
};
}
export function normalizeAttachment(input = {}) {
const txId = String(input.ar || input.txId || '').trim();
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const size = Number(input.size || input.sizeBytes || 0);
const name = normalizeName(input.name || input.fileName || 'file');
const preview = normalizePreview(input.preview || {
previewAr: input.previewAr,
previewSha256: input.previewSha256,
});
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
const out = {
v: 1,
name,
size,
sha256: sha256Hex,
ar: txId,
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
};
if (preview) {
out.v = 2;
out.preview = preview;
}
return out;
}
export function buildAttachmentBlock(attachment) {
const item = normalizeAttachment(attachment);
const encodedName = encodeURIComponent(item.name);
const previewFields = item.preview
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
: '';
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim();
const rows = (Array.isArray(attachments) ? attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(buildAttachmentBlock);
if (!rows.length) return cleanText;
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
}
function parseFields(rawFields) {
const out = {};
String(rawFields || '').split(';').forEach((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) out[key] = value;
});
return out;
}
export function parseMessageAttachments(rawText) {
let rest = String(rawText || '');
const attachments = [];
while (rest.startsWith(ATTACH_PREFIX)) {
const match = rest.match(ATTACH_BLOCK_RE);
if (!match) break;
const fields = parseFields(match[1]);
try {
attachments.push(normalizeAttachment({
name: decodeURIComponent(String(fields.name || 'file')),
size: fields.size,
sha256: fields.sha256,
ar: fields.ar,
previewAr: fields.previewAr,
previewSha256: fields.previewSha256,
}));
} catch {
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
}
rest = rest.slice(match[0].length);
}
return {
attachments,
text: rest.replace(/^\n+/u, ''),
};
}
export function getAttachmentExtension(name) {
const clean = String(name || '').trim().toLowerCase();
const dot = clean.lastIndexOf('.');
if (dot < 0 || dot === clean.length - 1) return '';
return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
}
export function getAttachmentKind(attachment = {}) {
const ext = getAttachmentExtension(attachment.name);
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
return 'file';
}
function isRecentMessage(messageTimestampMs) {
const ts = Number(messageTimestampMs || 0);
if (!Number.isFinite(ts) || ts <= 0) return false;
return Date.now() - ts < RECENT_UNAVAILABLE_MS;
}
function createUnavailableBlock(messageTimestampMs) {
const block = document.createElement('div');
block.className = 'message-attachment-unavailable';
block.textContent = isRecentMessage(messageTimestampMs)
? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.'
: 'Файл недоступен.';
return block;
}
function stopVideos(root) {
root.querySelectorAll('video').forEach((video) => {
try {
video.pause();
video.currentTime = 0;
} catch {
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
}
});
}
function createDownloadLink(url, label = 'Скачать') {
const link = document.createElement('a');
link.className = 'message-attachment-download';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.download = '';
link.textContent = label;
link.addEventListener('click', (event) => {
event.stopPropagation();
});
return link;
}
function openAttachmentViewer({ item, url, kind }) {
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'modal attachment-viewer-modal';
overlay.innerHTML = `
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
<div class="attachment-viewer-head">
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="attachment-viewer-body"></div>
<div class="attachment-viewer-actions"></div>
</div>
`;
const close = () => {
stopVideos(overlay);
overlay.remove();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
const body = overlay.querySelector('.attachment-viewer-body');
if (kind === 'video') {
const video = document.createElement('video');
video.className = 'attachment-viewer-media';
video.src = url;
video.controls = true;
video.autoplay = true;
body.append(video);
} else {
const img = document.createElement('img');
img.className = 'attachment-viewer-media';
img.src = url;
img.alt = item.name;
body.append(img);
}
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
root.append(overlay);
}
function replaceWithUnavailable(slide, messageTimestampMs) {
if (!slide || slide.dataset.unavailable === '1') return;
slide.dataset.unavailable = '1';
slide.replaceChildren(createUnavailableBlock(messageTimestampMs));
}
function createFileCard({ item, url, messageTimestampMs, slide }) {
const card = document.createElement('div');
card.className = 'message-attachment-file-card';
card.role = 'button';
card.tabIndex = 0;
card.title = 'Скачать файл';
card.innerHTML = `
<div class="message-attachment-file-ext">Файл</div>
<div class="message-attachment-file-main">
<div class="message-attachment-name">${escapeHtml(item.name)}</div>
<div class="message-attachment-meta">${escapeHtml(formatBytes(item.size))}</div>
</div>
`;
const openFile = () => window.open(url, '_blank', 'noopener');
card.addEventListener('click', (event) => {
event.stopPropagation();
openFile();
});
card.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openFile();
});
void fetch(url, { method: 'HEAD', cache: 'no-store' })
.then((response) => {
if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs);
})
.catch(() => replaceWithUnavailable(slide, messageTimestampMs));
return card;
}
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
const frame = document.createElement('div');
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
frame.role = 'button';
frame.tabIndex = 0;
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
frame.addEventListener('click', (event) => {
event.stopPropagation();
openAttachmentViewer({ item, url, kind });
});
frame.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openAttachmentViewer({ item, url, kind });
});
if (kind === 'image') {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = url;
img.alt = item.name;
img.loading = 'lazy';
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
frame.append(img);
} else if (previewUrl) {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = previewUrl;
img.alt = `${item.name} preview`;
img.loading = 'lazy';
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
play.textContent = '▶';
frame.append(img, play);
} else {
const video = document.createElement('video');
video.className = 'message-attachment-media';
video.src = url;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
play.textContent = '▶';
frame.append(video, play);
}
frame.append(createDownloadLink(url));
return frame;
}
export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) {
const items = (Array.isArray(attachments) ? attachments : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map((raw) => {
try {
return normalizeAttachment(raw);
} catch {
return null;
}
})
.filter(Boolean);
const wrap = document.createElement('div');
wrap.className = 'message-attachments message-attachment-carousel';
wrap.addEventListener('click', (event) => {
event.stopPropagation();
});
if (!items.length) return wrap;
let index = 0;
let pointerStartX = 0;
const viewport = document.createElement('div');
viewport.className = 'message-attachment-carousel-viewport';
const slide = document.createElement('div');
slide.className = 'message-attachment-slide';
const prev = document.createElement('button');
prev.type = 'button';
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
prev.textContent = '';
prev.setAttribute('aria-label', 'Предыдущее вложение');
const next = document.createElement('button');
next.type = 'button';
next.className = 'message-attachment-arrow message-attachment-arrow--next';
next.textContent = '';
next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div');
counter.className = 'message-attachment-counter';
const render = () => {
stopVideos(wrap);
const item = items[index];
const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
slide.dataset.unavailable = '0';
slide.replaceChildren();
slide.append(kind === 'file'
? createFileCard({ item, url, messageTimestampMs, slide })
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.textContent = `${index + 1} из ${items.length}`;
};
const move = (delta) => {
const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta));
if (nextIndex === index) return;
index = nextIndex;
render();
};
prev.addEventListener('click', (event) => {
event.stopPropagation();
move(-1);
});
next.addEventListener('click', (event) => {
event.stopPropagation();
move(1);
});
viewport.addEventListener('pointerdown', (event) => {
pointerStartX = event.clientX;
});
viewport.addEventListener('pointerup', (event) => {
const dx = event.clientX - pointerStartX;
if (Math.abs(dx) < 40) return;
move(dx < 0 ? 1 : -1);
});
viewport.append(slide, prev, next);
wrap.append(viewport, counter);
render();
return wrap;
}
export function createAttachmentListElement(attachments = [], options = {}) {
return createAttachmentCarouselElement(attachments, options);
}
+195 -124
View File
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_REPOST = 30;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@@ -64,6 +65,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
const SESSION_TYPE_CLIENT = 1;
const SESSION_TYPE_WALLET = 50;
const SESSION_TYPE_HOMESERVER = 100;
@@ -690,14 +692,47 @@ function makeConnectionBodyBytes({
}
function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const bytes = utf8Bytes(text);
if (bytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
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) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
@@ -737,8 +772,8 @@ function makeCreateChannelBodyBytes({
}
const descriptionBytes = utf8Bytes(cleanDescription);
if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
}
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
@@ -762,31 +797,6 @@ function makeCreateChannelBodyBytes({
);
}
function makeCreateChannelBodyBytesLegacy({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName,
}) {
const check = validatePersonalChannelName(channelName);
if (!check.ok) throw new Error(check.error);
const cleanName = check.normalized;
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
throw new Error('Channel name must be 1..255 bytes');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(nameBytes.length),
nameBytes,
);
}
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
@@ -806,6 +816,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') {
const cleanBch = String(target?.blockchainName || '').trim();
const cleanBlockNumber = Number(target?.blockNumber);
@@ -2033,6 +2060,8 @@ export class AuthService {
login,
channelName,
channelDescription = '',
channelProfileTitle = '',
channelAvatar = null,
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd,
@@ -2048,7 +2077,13 @@ export class AuthService {
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
}
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
const cleanChannelDescription = typeCode === CHANNEL_TYPE_PUBLIC
? composeChannelMetaText({
title: channelProfileTitle,
description: channelDescription,
avatar: channelAvatar,
})
: normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
@@ -2080,51 +2115,23 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
let payload;
try {
payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
} catch (error) {
const rawCode = String(error?.code || '').toUpperCase();
const rawText = String(error?.message || '').toLowerCase();
const isLegacyFormatMismatch = (
rawCode === 'BAD_BLOCK_FORMAT' ||
rawText.includes('bad_block_format') ||
rawText.includes('некорректный формат блока')
);
if (!isLegacyFormatMismatch) throw error;
// Совместимость со старыми серверами, где CreateChannel body без description/type.
payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: 1,
bodyBytes: makeCreateChannelBodyBytesLegacy({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
}),
});
}
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
const selector = {
ownerBlockchainName: blockchainName,
@@ -2141,6 +2148,69 @@ 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 = latest?.kind === 'created'
? 0
: Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: (latestBlockNumber === lineCode ? 0 : 1);
}
} catch {
// fallback to root anchor
}
return {
ownerBlockchainName,
lineCode,
rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
};
}
async addBlockTextPost({ login, channel, text, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -2154,53 +2224,13 @@ export class AuthService {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const ownerBlockchainName = owner;
const lineCode = root;
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
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 tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextPostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: cleanText,
});
@@ -2216,9 +2246,50 @@ export class AuthService {
return {
...payload,
channel: {
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
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,
},
};
});
+12 -10
View File
@@ -10,6 +10,8 @@ function decodeRoutePart(value = '') {
}
}
const SHINE_ROUTE_PREFIX = 'SHiNE.';
export function normalizeLogin(value = '') {
return String(value || '').trim().replace(/^@+/, '');
}
@@ -23,21 +25,21 @@ export function extractLoginFromBlockchainName(value = '') {
export function makeProfileRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile';
return clean ? `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}` : 'profile';
}
export function makeProfileLinksRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network/keep-history';
return clean ? `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/links` : 'network/keep-history';
}
export function makeProfileChannelsRoute(login = '', scope = 'all') {
const clean = normalizeLogin(login);
if (!clean) return 'channels/feed';
const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
return `shine.${encodeRoutePart(clean)}/channels`;
if (normalizedScope === 'owned') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/following`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels`;
}
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
@@ -45,19 +47,19 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!ownerBch || !chName) return '';
if (msgNo) return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
if (msgNo) return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!msgBch || !msgNo) return '';
return `shine.${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
}
export function parseShineRootSegment(segment = '') {
const raw = String(segment || '').trim();
if (!raw.toLowerCase().startsWith('shine.')) return '';
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
if (!raw.toLowerCase().startsWith(SHINE_ROUTE_PREFIX.toLowerCase())) return '';
return normalizeLogin(decodeRoutePart(raw.slice(SHINE_ROUTE_PREFIX.length)));
}
+105 -3
View File
@@ -17,6 +17,34 @@ const BASE58_MAP = (() => {
return out;
})();
function encodeBase58(bytesLike) {
const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []);
if (!bytes.length) return '';
const digits = [0];
for (let i = 0; i < bytes.length; i += 1) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j += 1) {
const value = (digits[j] << 8) + carry;
digits[j] = value % 58;
carry = (value / 58) | 0;
}
while (carry > 0) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
let out = '';
for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) {
out += BASE58_ALPHABET[0];
}
for (let i = digits.length - 1; i >= 0; i -= 1) {
out += BASE58_ALPHABET[digits[i]];
}
return out;
}
function normalizeEndpoint(url) {
const raw = String(url || '').trim();
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
@@ -65,13 +93,27 @@ function decodeBase58(input) {
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
return solana.Keypair.fromSeed(seed32);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
seed32.fill(0);
}
}
async function keypairFromStoredSecret(storedSecret) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(storedSecret);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
seed32.fill(0);
}
}
export async function createRandomSolanaWallet() {
const solana = await loadSolanaLib();
const keypair = solana.Keypair.generate();
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32));
return {
address: keypair.publicKey.toBase58(),
privateKey32Base58,
@@ -107,7 +149,7 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
if (!clientPrivate) {
throw new Error('На устройстве не найден client.key');
}
const keypair = await keypairFromPkcs8(clientPrivate);
const keypair = await keypairFromStoredSecret(clientPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
@@ -115,6 +157,66 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
};
}
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к root key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const rootPrivate = String(secrets?.rootKey || '').trim();
if (!rootPrivate) {
throw new Error('На устройстве не найден root key');
}
const keypair = await keypairFromStoredSecret(rootPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
rootPrivatePkcs8B64: rootPrivate,
};
}
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к ключам');
}
const choices = [];
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
choices.push({
id: 'client-key',
keySource: 'client',
label: 'client key',
address: clientWallet.address,
keypair: clientWallet.keypair,
});
if (includeRoot) {
try {
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
choices.push({
id: 'root-key',
keySource: 'root',
label: 'root key',
address: rootWallet.address,
keypair: rootWallet.keypair,
});
} catch {
// root key на устройстве может отсутствовать, это допустимо
}
}
return choices;
}
export async function encodeSolanaSecretKeyBase58(secretKey) {
const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []);
if (!bytes.length) throw new Error('Пустой Solana secret key');
return encodeBase58(bytes);
}
export async function getBalanceSol({ endpoint, address }) {
const solana = await loadSolanaLib();
const rpc = normalizeEndpoint(endpoint);
@@ -0,0 +1,213 @@
import { getBalanceSol, getStoredSolanaWalletChoices, encodeSolanaSecretKeyBase58, formatSol } from './solana-wallet-service.js';
import { state } from '../state.js';
const FREE_TURBO_UPLOAD_BYTES = 100 * 1024;
const WINC_PER_CREDIT = 1_000_000_000_000n;
let turboLibPromise = null;
function parseSolToLamports(amountSol) {
const raw = String(amountSol ?? '').trim().replace(',', '.');
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
if (!match) {
throw new Error('Сумма пополнения должна быть числом');
}
const intPart = BigInt(match[1] || '0');
const frac = String(match[2] || '');
if (frac.length > 9) {
throw new Error('Слишком много знаков после запятой для SOL');
}
const fracPadded = `${frac}${'0'.repeat(9 - frac.length)}`;
const lamports = (intPart * 1_000_000_000n) + BigInt(fracPadded || '0');
if (lamports <= 0n) {
throw new Error('Сумма пополнения должна быть больше 0');
}
return lamports.toString();
}
function normalizeKeySource(value) {
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
}
async function loadTurboLib() {
if (!turboLibPromise) {
turboLibPromise = import('../vendor/turbo-sdk.web.bundle.js');
}
return turboLibPromise;
}
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
const normalizedKeySource = normalizeKeySource(keySource);
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
if (!selected?.keypair) {
throw new Error(normalizedKeySource === 'root'
? 'На устройстве не найден root key для Turbo.'
: 'На устройстве не найден client key для Turbo.');
}
return selected;
}
async function createTurboClientFromWalletChoice(choice) {
const moduleRef = await loadTurboLib();
const TurboFactory = moduleRef?.TurboFactory;
if (!TurboFactory?.authenticated) {
throw new Error('Turbo SDK не удалось инициализировать.');
}
const privateKey = await encodeSolanaSecretKeyBase58(choice.keypair.secretKey);
return TurboFactory.authenticated({
privateKey,
token: 'solana',
});
}
function creditsFromWinc(winc) {
const value = BigInt(String(winc || '0'));
return Number(value) / Number(WINC_PER_CREDIT);
}
function shortAddress(value) {
const raw = String(value || '').trim();
if (raw.length <= 16) return raw;
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
}
export function isFreeTurboUploadSize(byteLength) {
const bytes = Number(byteLength || 0);
return Number.isFinite(bytes) && bytes > 0 && bytes <= FREE_TURBO_UPLOAD_BYTES;
}
export function getFreeTurboUploadBytesLimit() {
return FREE_TURBO_UPLOAD_BYTES;
}
export function formatTurboCredits(value, digits = 6) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: digits,
});
}
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
const turbo = await createTurboClientFromWalletChoice(choice);
return {
turbo,
address: choice.address,
keySource: choice.keySource,
label: choice.label,
keypair: choice.keypair,
shortAddress: shortAddress(choice.address),
};
}
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const balance = await ctx.turbo.getBalance(ctx.address);
const solanaBalance = await getBalanceSol({
endpoint: state.registrationPayment?.endpoint || state.entrySettings?.solanaRpc || undefined,
address: ctx.address,
});
return {
...ctx,
controlledWinc: String(balance.controlledWinc || '0'),
spendableWinc: String(balance.winc || '0'),
effectiveWinc: String(balance.effectiveBalance || '0'),
controlledCredits: creditsFromWinc(balance.controlledWinc || '0'),
spendableCredits: creditsFromWinc(balance.winc || '0'),
effectiveCredits: creditsFromWinc(balance.effectiveBalance || '0'),
solBalance: solanaBalance.sol,
solLamports: solanaBalance.lamports,
};
}
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
const bytes = Number(byteLength);
if (!Number.isInteger(bytes) || bytes <= 0) {
throw new Error('Некорректный размер файла для Turbo.');
}
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const rows = await ctx.turbo.getUploadCosts({ bytes: [bytes] });
const price = Array.isArray(rows) ? rows[0] : null;
if (!price) {
throw new Error('Turbo не вернул цену загрузки.');
}
const winc = String(price.winc || '0');
return {
...ctx,
byteLength: bytes,
isFree: isFreeTurboUploadSize(bytes),
winc,
credits: creditsFromWinc(winc),
};
}
export async function uploadFileWithTurbo({
login,
storagePwd,
keySource = 'client',
file,
tags = [],
shineType = 'attachment',
onProgress,
} = {}) {
if (!(file instanceof File)) {
throw new Error('Выберите файл для загрузки через Turbo.');
}
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const result = await ctx.turbo.uploadFile({
file,
dataItemOpts: {
tags: [
{ name: 'Content-Type', value: String(file.type || 'application/octet-stream') },
{ name: 'App-Name', value: 'SHiNE' },
{ name: 'SHiNE-Type', value: String(shineType || 'attachment') },
...((Array.isArray(tags) ? tags : []).filter((item) => item?.name && item?.value)),
],
},
events: typeof onProgress === 'function'
? {
onProgress: ({ totalBytes, processedBytes, step }) => {
onProgress({
totalBytes,
processedBytes,
step,
});
},
}
: undefined,
});
return {
...ctx,
id: String(result?.id || '').trim(),
owner: String(result?.owner || '').trim(),
winc: String(result?.winc || '0'),
credits: creditsFromWinc(result?.winc || '0'),
};
}
export async function topUpTurboWithSolana({
login,
storagePwd,
keySource = 'client',
amountSol,
turboCreditDestinationAddress = '',
} = {}) {
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const tokenAmount = parseSolToLamports(amountSol);
const result = await ctx.turbo.topUpWithTokens({
tokenAmount,
turboCreditDestinationAddress: String(turboCreditDestinationAddress || '').trim() || undefined,
});
return {
...ctx,
amountSol: formatSol(amountSol, 9),
lamports: tokenAmount,
txId: String(result?.id || '').trim(),
target: String(result?.target || '').trim(),
rewardWinc: String(result?.reward || '0'),
rewardCredits: creditsFromWinc(result?.reward || '0'),
};
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
# Черновик `TEXT_CHANNEL_META` для каналов SHiNE
## Статус документа
Этот файл является рабочим черновиком и не считается финальной спецификацией.
Важно:
- это не окончательный формат;
- точная версия должна быть зафиксирована отдельно при начале реализации;
- до реализации допустимы изменения синтаксиса, ограничений и полей БД;
- документ временно лежит в корне проекта как согласованный draft.
## Назначение
Нужен специальный скрытый тип текстового сообщения канала, который:
- технически хранится в блокчейне как `TEXT`-блок;
- не показывается пользователям как обычное сообщение ленты;
- может писать только владелец канала;
- не редактируется;
- задаёт полное текущее состояние метаданных канала.
Через этот блок должны обновляться:
- человекочитаемое заглавие канала;
- аватар канала;
- описание канала.
## Место в формате блокчейна
- `msg_type = 1`
- `subType = 70`
- имя подтипа: `TEXT_CHANNEL_META`
Этот подтип относится к текстовым блокам, но трактуется как скрытое техническое сообщение канала.
## Общая модель применения
`TEXT_CHANNEL_META`:
- пишется в линию конкретного канала;
- задаёт полный снимок метаданных;
- при чтении канала сервер берёт последний валидный `TEXT_CHANNEL_META` в линии;
- более старые `TEXT_CHANNEL_META` остаются в истории блокчейна, но не участвуют в вычислении текущего состояния;
- если meta-блок не найден, используются базовые данные канала без override-полей.
## Общий формат текста
Внутри `TEXT_CHANNEL_META` текст строится так:
1. в начале идут технические теги `<SHiNE:...>`;
2. после тегов идёт обычный UTF-8 текст;
3. этот хвостовой текст считается описанием канала.
Пример:
```text
<SHiNE:title;v=1;Моё имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
Тут описание канала.
```
## Поддерживаемые теги
### 1. Тег заглавия
Формат:
```text
<SHiNE:title;v=1;Моё имя канала>
```
Смысл:
- задаёт красивое отображаемое имя канала;
- хранится как обычный UTF-8 текст;
- это не технический slug и не системное имя.
### 2. Тег аватара
Формат:
```text
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
```
Смысл:
- задаёт аватар канала;
- `ar` хранит только `txId`, без полного URL;
- конкретный gateway/URL уже выбирает клиент или сервер.
## Правила порядка
- теги должны идти только в начале текста;
- после начала обычного описания новые теги больше не допускаются;
- рекомендуемый порядок:
1. `title`
2. `avatar`
3. текст описания
- каждый тег допустим не более одного раза.
## Правила отсутствующих полей
- если `title` отсутствует, используется обычное техническое имя канала;
- если `avatar` отсутствует, аватар у канала отсутствует;
- если текста после тегов нет, описание считается пустым;
- если тегов нет вообще, но текст есть, это означает пустые `title` и `avatar`, а текст используется как описание.
## Полный снимок состояния
Каждый новый `TEXT_CHANNEL_META` задаёт полное состояние канала целиком.
Это значит:
- новый блок не дозаполняет старые поля;
- новый блок не наследует значения из прошлого meta-блока;
- если в новом блоке нет `title`, то красивое имя сбрасывается к обычному имени канала;
- если в новом блоке нет `avatar`, аватар сбрасывается;
- если в новом блоке нет текста описания, описание становится пустым.
## Ограничения для тега `title`
`title` хранится как UTF-8 строка, но для упрощения формата запрещаются следующие символы:
- `<`
- `>`
- `;`
- табуляция
- перевод строки
- `\0`
Дополнительно:
- максимальная длина `title``50` символов;
- допускаются обычные Unicode-символы, включая кириллицу, латиницу, эмодзи и декоративные символы, если они не нарушают запреты выше.
## Ограничения для описания
- описание хранится как обычный UTF-8 текст;
- максимальная длина — `250` символов.
Если описание длиннее лимита, meta-блок должен считаться невалидным.
## Ограничения для тега `avatar`
Обязательные поля тега:
- `v`
- `size`
- `sha256`
- `ar`
Правила:
- `size` — размер файла в байтах;
- `sha256` — SHA-256 файла в hex;
- `sha256` должен иметь длину ровно `64` hex-символа;
- `ar` — только `txId` Arweave;
- длина `ar` должна соответствовать длине идентификатора Arweave-транзакции;
- имя файла в meta-теге аватара не хранится.
## Валидация блока
Блок `TEXT_CHANNEL_META` считается валидным только если одновременно соблюдены все условия:
- теги находятся строго в начале текста;
- каждый тип тега встречается не более одного раза;
- `title` удовлетворяет ограничениям;
- `avatar`-тег имеет все обязательные поля и корректные значения;
- длина описания не превышает лимит.
Если блок невалиден:
- весь meta-блок не применяется целиком;
- сервер не должен частично брать из него только отдельные поля.
## Что именно хранится в текущем состоянии канала
Текущее вычисленное состояние канала должно включать:
- техническое имя канала;
- красивое отображаемое имя;
- описание;
- `ava_ar`;
- `ava_sha256`;
- `ava_size`;
- время последнего meta-обновления.
## База данных
Базовую таблицу каналов предлагается не менять концептуально, а расширить существующую модель хранения каналов.
Текущая основа уже есть в таблице:
- `channel_names_state`
В неё предлагается добавить поля:
- `ava_ar`
- `ava_sha256`
- `ava_size`
- `meta_updated_at_ms`
При этом:
- техническое имя канала продолжает храниться отдельно;
- красивое имя хранится в `display_name`;
- описание хранится в `channel_description`.
## Поведение API и чтения каналов
Обычным пользователям `TEXT_CHANNEL_META` не должен приходить как обычное сообщение ленты.
Это означает:
- в `GetChannelMessages` такие блоки не показываются как сообщения;
- в обычных списках каналов клиент получает уже вычисленное текущее состояние канала;
- в debug/raw-чтении сам блок может оставаться доступным как часть блокчейна.
## UI-заметки
### 1. В обычной ленте
`TEXT_CHANNEL_META` не показывается как обычное текстовое сообщение.
### 2. Системная карточка
UI может показывать специальную системную карточку:
- для текущего состояния канала;
- для стартового состояния при создании канала.
### 3. Что показывать по нажатию
По нажатию на системную карточку нужно показывать полную информацию:
- дата и время;
- заглавие;
- аватар;
- описание.
### 4. История UI
На первом этапе UI показывает только текущую системную карточку состояния.
Полная история всех прошлых meta-обновлений в UI на этом этапе не требуется.
## Поведение при создании канала
Для UI полезно также иметь отдельный визуальный системный блок в начале канала:
- «канал создан»;
- по нажатию можно показать стартовые данные канала;
- если на момент создания уже есть заглавие/ава/описание, UI показывает именно их.
Это UI-заметка и не требует отдельного нового блокчейн-формата поверх `TEXT_CHANNEL_META`.
## Краткое резюме согласованной схемы
- `msg_type=1`
- `subType=70`
- имя: `TEXT_CHANNEL_META`
- это скрытый технический текстовый блок канала
- порядок: теги в начале, потом описание
- теги:
- `<SHiNE:title;v=1;...>`
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`
- `title`:
- UTF-8
- максимум `50` символов
- нельзя `<`, `>`, `;`, таб, перевод строки, `\0`
- описание:
- UTF-8
- максимум `250` символов
- `avatar`:
- `ar` только `txId`
- хранится без имени файла
- новый meta-блок задаёт полный снимок состояния
- сервер применяет только последний валидный meta-блок
- невалидный meta-блок не применяется целиком
- БД расширяется в той же модели каналов
- в UI обычным сообщением блок не показывается
## Что ещё потребуется зафиксировать позже
При переводе черновика в официальную спецификацию нужно будет дополнительно утвердить:
- точный номер версии формата;
- точный способ подсчёта длины `title` и описания:
- по Unicode code points;
- по Java `String.length()`;
- или по UTF-8 байтам;
- точную проверку длины `ar`;
- список API-ответов, куда должны быть добавлены новые поля;
- правила миграции старых каналов без meta-блоков.
@@ -0,0 +1,173 @@
# Черновик формата вложений в сообщениях блокчейна SHiNE
## Статус документа
Этот файл пока является черновиком и рабочим описанием идеи.
Важно:
- это не финальная спецификация;
- точная версия формата будет зафиксирована после начала реальной реализации;
- до момента реализации допустимы изменения полей, синтаксиса и правил отображения;
- документ специально лежит в корне проекта как временная рабочая заметка.
## Цель
Нужен минимальный формат, позволяющий прикладывать файлы к текстовым сообщениям в каналах и тредах, не превращая само сообщение в отдельный файловый контейнер.
Основная идея:
- само сообщение остаётся обычным текстом;
- вложения описываются специальными техническими вставками в начале текста;
- сами файлы хранятся отдельно в архиве/Arweave;
- в сообщении хранятся только метаданные, достаточные для отображения и скачивания.
## Общий принцип
Если в начале текста сообщения стоит один или несколько блоков:
```text
<SHiNE:attach;...>
```
клиент трактует их как описания вложений.
После этих блоков идёт обычный пользовательский текст сообщения.
Если технических блоков нет, сообщение считается обычным текстовым сообщением без вложений.
## Формат одного блока вложения
Общий вид:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=abcdef...;ar=tx_or_url>
```
Где:
- `attach` — тип технической вставки;
- `v=1` — текущая рабочая версия чернового формата;
- `name` — имя файла;
- `size` — размер файла в байтах;
- `sha256` — SHA-256 файла в hex;
- `ar` — идентификатор файла в архиве или адрес файла в Arweave.
## Несколько вложений
Если к одному сообщению приложено несколько файлов, блоки просто идут подряд в начале текста:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaa...;ar=...>
<SHiNE:attach;v=1;name=video.mp4;size=5820193;sha256=bbb...;ar=...>
Текст сообщения
```
## Обязательные поля
Для чернового варианта обязательными считаются:
- `v`
- `name`
- `size`
- `sha256`
- `ar`
Если какого-то из этих полей нет, клиент может:
- игнорировать конкретный битый блок;
- не считать его валидным вложением;
- при этом продолжать обрабатывать остальные корректные блоки.
## Почему без `mime` и без `kind`
В минимальном варианте тип файла отдельно не хранится.
Причины:
- это экономит место;
- тип всё равно обычно определяется клиентом;
- для первого варианта достаточно имени файла, адреса, хэша и размера;
- превью и способ отображения клиент может решать сам по расширению имени файла или по попытке открыть файл.
То есть в сообщении не хранится:
- `mime`;
- `kind`;
- `width`;
- `height`;
- `duration`;
- `thumbnail`.
Все эти поля можно добавить позже отдельной версией, если они реально понадобятся.
## Логика клиента
Клиент получает:
- имя файла;
- размер;
- хэш;
- адрес файла.
После этого клиент сам решает, как рендерить вложение:
- если расширение похоже на изображение, можно пробовать показать preview;
- если расширение похоже на видео, можно пробовать встроенный видеоплеер;
- если расширение похоже на аудио, можно пробовать аудиоплеер;
- иначе показывать обычную кнопку скачивания.
Если браузер или клиент не смог показать preview, должен быть fallback на скачивание файла.
## Правила совместимости
- Сообщение без `<SHiNE:attach;...>` остаётся обычным текстом.
- Старые клиенты, которые не умеют распознавать этот формат, могут показывать сообщение как обычный текст целиком.
- Новые клиенты могут скрывать технические блоки и показывать вместо них UI вложений.
## Ограничения чернового варианта
В текущем виде значения полей не предполагают сложного экранирования.
Значит нужно отдельно договориться, как безопасно хранить `name`, если там встретятся символы:
- `;`
- `=`
- `>`
На этапе реальной реализации нужно выбрать один из вариантов:
- жёстко ограничить допустимые символы в `name`;
- хранить имя файла в безопасно закодированном виде;
- добавить отдельное правило escape/encoding.
Пока этот вопрос считается открытым.
## Примеры
### Одно вложение
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=3f2c8a...;ar=6sYk...>
Привет, вот фото
```
### Два вложения
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=3f2c8a...;ar=6sYk...>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=9ab01e...;ar=K9Lp...>
Смотри, прикрепил фото и документ
```
## Что надо зафиксировать позже
Перед переводом этого черновика в официальную спецификацию нужно отдельно утвердить:
- точное место этого формата в общей документации блокчейна;
- где именно используются такие вложения: каналы, треды, комментарии или иные текстовые блоки;
- финальные правила кодирования `name`;
- допустим ли полный URL в `ar` или только короткий `txId`;
- нужно ли ограничение на количество вложений в одном сообщении;
- нужна ли отдельная серверная или клиентская проверка расширения файла;
- как именно это будет отображаться в web UI и других клиентах.