SHA256
Merge ветки расширения функционала каналов в main
This commit is contained in:
@@ -21,10 +21,18 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_2 = 2;
|
public static final int SCHEMA_VERSION_2 = 2;
|
||||||
public static final int SCHEMA_VERSION_3 = 3;
|
public static final int SCHEMA_VERSION_3 = 3;
|
||||||
public static final int SCHEMA_VERSION_4 = 4;
|
public static final int SCHEMA_VERSION_4 = 4;
|
||||||
|
public static final int SCHEMA_VERSION_5 = 5;
|
||||||
|
public static final int SCHEMA_VERSION_6 = 6;
|
||||||
|
public static final int SCHEMA_VERSION_7 = 7;
|
||||||
|
public static final int SCHEMA_VERSION_8 = 8;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -100,6 +108,22 @@ public final class DatabaseInitializer {
|
|||||||
}
|
}
|
||||||
if (currentVersion < SCHEMA_VERSION_4) {
|
if (currentVersion < SCHEMA_VERSION_4) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_4;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_5) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_5;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_6) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_6;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_7) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_7;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_8) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
|
|||||||
blockchain_key,
|
blockchain_key,
|
||||||
client_key
|
client_key
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
|
|||||||
blockchain_key,
|
blockchain_key,
|
||||||
client_key
|
client_key
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE LOWER(login) LIKE ?
|
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||||
AND (? IS NULL OR is_server = ?)
|
AND (? IS NULL OR is_server = ?)
|
||||||
ORDER BY login
|
ORDER BY login
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
|
|||||||
List<CurrentUserEntry> result = new ArrayList<>();
|
List<CurrentUserEntry> result = new ArrayList<>();
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, prefix.toLowerCase() + "%");
|
ps.setString(1, prefix.trim() + "%");
|
||||||
if (isServer == null) {
|
if (isServer == null) {
|
||||||
ps.setNull(2, Types.BOOLEAN);
|
ps.setNull(2, Types.BOOLEAN);
|
||||||
ps.setNull(3, Types.BOOLEAN);
|
ps.setNull(3, Types.BOOLEAN);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
|
|||||||
(
|
(
|
||||||
SELECT
|
SELECT
|
||||||
current_users.login AS login,
|
current_users.login AS login,
|
||||||
|
current_users.normalized_login AS normalized_login,
|
||||||
current_users.blockchain_name AS blockchain_name,
|
current_users.blockchain_name AS blockchain_name,
|
||||||
current_users.client_key AS solana_key,
|
current_users.client_key AS solana_key,
|
||||||
current_users.blockchain_key AS blockchain_key,
|
current_users.blockchain_key AS blockchain_key,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
|
||||||
|
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET normalized_login = LOWER(BTRIM(login))
|
||||||
|
WHERE normalized_login IS NULL
|
||||||
|
OR normalized_login <> LOWER(BTRIM(login));
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ALTER COLUMN normalized_login SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||||
|
ON solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||||
|
ON solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
|
WITH bad_signed_messages AS (
|
||||||
|
SELECT DISTINCT sm.message_key
|
||||||
|
FROM signed_messages sm
|
||||||
|
LEFT JOIN solana_user_pda_current u_from
|
||||||
|
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||||
|
LEFT JOIN solana_user_pda_current u_to
|
||||||
|
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||||
|
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||||
|
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||||
|
)
|
||||||
|
DELETE FROM signed_message_session_delivery d
|
||||||
|
USING bad_signed_messages bad
|
||||||
|
WHERE d.message_key = bad.message_key;
|
||||||
|
|
||||||
|
WITH bad_signed_messages AS (
|
||||||
|
SELECT DISTINCT sm.message_key
|
||||||
|
FROM signed_messages sm
|
||||||
|
LEFT JOIN solana_user_pda_current u_from
|
||||||
|
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||||
|
LEFT JOIN solana_user_pda_current u_to
|
||||||
|
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||||
|
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||||
|
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||||
|
)
|
||||||
|
DELETE FROM signed_messages sm
|
||||||
|
USING bad_signed_messages bad
|
||||||
|
WHERE sm.message_key = bad.message_key;
|
||||||
|
|
||||||
|
DELETE FROM signed_direct_messages_history h
|
||||||
|
USING solana_user_pda_current u_from,
|
||||||
|
solana_user_pda_current u_to
|
||||||
|
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
|
||||||
|
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
|
||||||
|
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
|
||||||
|
|
||||||
|
DELETE FROM signed_direct_message_replay r
|
||||||
|
USING solana_user_pda_current u_from
|
||||||
|
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
|
||||||
|
AND r.from_login <> u_from.login;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'direct_messages'
|
||||||
|
) THEN
|
||||||
|
DELETE FROM direct_messages d
|
||||||
|
USING solana_user_pda_current u_from,
|
||||||
|
solana_user_pda_current u_to
|
||||||
|
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
|
||||||
|
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
|
||||||
|
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 5, 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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE signed_messages
|
||||||
|
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE signed_messages
|
||||||
|
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE signed_direct_messages_history
|
||||||
|
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE signed_direct_messages_history
|
||||||
|
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 6, 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;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE blocks
|
||||||
|
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE blocks
|
||||||
|
ADD CONSTRAINT blocks_login_fkey
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 7, 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;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE connections_state
|
||||||
|
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE connections_state
|
||||||
|
ADD CONSTRAINT connections_state_login_fkey
|
||||||
|
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 8, 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;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login
|
|||||||
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||||
pda_address TEXT PRIMARY KEY,
|
pda_address TEXT PRIMARY KEY,
|
||||||
login TEXT NOT NULL UNIQUE,
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
normalized_login TEXT NOT NULL,
|
||||||
record_number INTEGER NOT NULL,
|
record_number INTEGER NOT NULL,
|
||||||
slot BIGINT NOT NULL,
|
slot BIGINT NOT NULL,
|
||||||
last_tx_signature TEXT NOT NULL,
|
last_tx_signature TEXT NOT NULL,
|
||||||
@@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
|
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
|
||||||
ON solana_user_pda_current(slot);
|
ON solana_user_pda_current(slot);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||||
|
ON solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||||
|
ON solana_user_pda_current(normalized_login);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_access_servers_current (
|
CREATE TABLE IF NOT EXISTS user_access_servers_current (
|
||||||
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
@@ -440,7 +447,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
|||||||
ON blockchain_state(updated_at_ms);
|
ON blockchain_state(updated_at_ms);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blocks (
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name),
|
bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name),
|
||||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
msg_type INTEGER NOT NULL,
|
msg_type INTEGER NOT NULL,
|
||||||
@@ -470,7 +477,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
|||||||
ON blocks (bch_name, line_code, this_line_number);
|
ON blocks (bch_name, line_code, this_line_number);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS connections_state (
|
CREATE TABLE IF NOT EXISTS connections_state (
|
||||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
rel_type INTEGER NOT NULL,
|
rel_type INTEGER NOT NULL,
|
||||||
to_login TEXT NOT NULL,
|
to_login TEXT NOT NULL,
|
||||||
to_bch_name TEXT NOT NULL,
|
to_bch_name TEXT NOT NULL,
|
||||||
@@ -624,8 +631,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||||
message_id TEXT PRIMARY KEY,
|
message_id TEXT PRIMARY KEY,
|
||||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
from_login TEXT NOT NULL,
|
||||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
to_login TEXT NOT NULL,
|
||||||
target_mode INTEGER NOT NULL,
|
target_mode INTEGER NOT NULL,
|
||||||
target_session_id TEXT,
|
target_session_id TEXT,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
@@ -642,8 +649,8 @@ CREATE TABLE IF NOT EXISTS signed_messages (
|
|||||||
message_key TEXT PRIMARY KEY,
|
message_key TEXT PRIMARY KEY,
|
||||||
base_key TEXT NOT NULL,
|
base_key TEXT NOT NULL,
|
||||||
target_login TEXT NOT NULL,
|
target_login TEXT NOT NULL,
|
||||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
from_login TEXT NOT NULL,
|
||||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
to_login TEXT NOT NULL,
|
||||||
time_ms BIGINT NOT NULL,
|
time_ms BIGINT NOT NULL,
|
||||||
nonce BIGINT NOT NULL,
|
nonce BIGINT NOT NULL,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
|
|||||||
+21
-5
@@ -6,6 +6,8 @@ import java.util.Map;
|
|||||||
public final class ChannelMetaTextParser {
|
public final class ChannelMetaTextParser {
|
||||||
public static final int MAX_TITLE_CHARS = 50;
|
public static final int MAX_TITLE_CHARS = 50;
|
||||||
public static final int MAX_DESCRIPTION_CHARS = 250;
|
public static final int MAX_DESCRIPTION_CHARS = 250;
|
||||||
|
private static final String LEGACY_PREFIX = "<SHiNE:";
|
||||||
|
private static final String SHORT_PREFIX = "<S:";
|
||||||
|
|
||||||
private ChannelMetaTextParser() {}
|
private ChannelMetaTextParser() {}
|
||||||
|
|
||||||
@@ -19,15 +21,16 @@ public final class ChannelMetaTextParser {
|
|||||||
boolean seenAvatar = false;
|
boolean seenAvatar = false;
|
||||||
|
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
while (text.startsWith("<SHiNE:", offset)) {
|
while (startsWithTagPrefix(text, offset)) {
|
||||||
int end = text.indexOf('>', offset);
|
int end = text.indexOf('>', offset);
|
||||||
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
||||||
String body = text.substring(offset + "<SHiNE:".length(), end);
|
int prefixLength = tagPrefixLength(text, offset);
|
||||||
|
String body = text.substring(offset + prefixLength, end);
|
||||||
if (body.startsWith("title;")) {
|
if (body.startsWith("title;")) {
|
||||||
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||||
seenTitle = true;
|
seenTitle = true;
|
||||||
title = parseTitleTag(body);
|
title = parseTitleTag(body);
|
||||||
} else if (body.startsWith("avatar;")) {
|
} else if (body.startsWith("avatar;") || body.startsWith("ava;")) {
|
||||||
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
||||||
seenAvatar = true;
|
seenAvatar = true;
|
||||||
Avatar avatar = parseAvatarTag(body);
|
Avatar avatar = parseAvatarTag(body);
|
||||||
@@ -61,11 +64,14 @@ public final class ChannelMetaTextParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Avatar parseAvatarTag(String body) {
|
private static Avatar parseAvatarTag(String body) {
|
||||||
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
|
String rawFields = body.startsWith("ava;")
|
||||||
|
? body.substring("ava;".length())
|
||||||
|
: body.substring("avatar;".length());
|
||||||
|
Map<String, String> fields = parseFields(rawFields);
|
||||||
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
||||||
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
||||||
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
||||||
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
|
String sizeRaw = String.valueOf(fields.containsKey("sz") ? fields.get("sz") : fields.getOrDefault("size", "")).trim();
|
||||||
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
|
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");
|
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
|
||||||
long size;
|
long size;
|
||||||
@@ -78,6 +84,16 @@ public final class ChannelMetaTextParser {
|
|||||||
return new Avatar(ar, sha256, size);
|
return new Avatar(ar, sha256, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean startsWithTagPrefix(String text, int offset) {
|
||||||
|
return text.startsWith(LEGACY_PREFIX, offset) || text.startsWith(SHORT_PREFIX, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int tagPrefixLength(String text, int offset) {
|
||||||
|
if (text.startsWith(LEGACY_PREFIX, offset)) return LEGACY_PREFIX.length();
|
||||||
|
if (text.startsWith(SHORT_PREFIX, offset)) return SHORT_PREFIX.length();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, String> parseFields(String raw) {
|
private static Map<String, String> parseFields(String raw) {
|
||||||
Map<String, String> out = new HashMap<>();
|
Map<String, String> out = new HashMap<>();
|
||||||
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||||
|
|||||||
+57
-31
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
|
|||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
public final class PostgresStorageRepository
|
public final class PostgresStorageRepository
|
||||||
implements AutoCloseable {
|
implements AutoCloseable {
|
||||||
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
|
|||||||
|
|
||||||
String sql =
|
String sql =
|
||||||
"INSERT INTO solana_user_pda_current (" +
|
"INSERT INTO solana_user_pda_current (" +
|
||||||
"pda_address, login, record_number, slot, last_tx_signature, " +
|
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
|
||||||
"recovery_key, root_key, client_key, blockchain_name, " +
|
"recovery_key, root_key, client_key, blockchain_name, " +
|
||||||
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
||||||
"last_block_number, last_block_hash, last_block_signature, " +
|
"last_block_number, last_block_hash, last_block_signature, " +
|
||||||
@@ -475,9 +476,10 @@ public final class PostgresStorageRepository
|
|||||||
"trusted_count, created_at_ms, updated_at_ms, " +
|
"trusted_count, created_at_ms, updated_at_ms, " +
|
||||||
"prev_record_hash, record_signature, raw_data_base64, " +
|
"prev_record_hash, record_signature, raw_data_base64, " +
|
||||||
"first_seen_at_ms, last_synced_at_ms" +
|
"first_seen_at_ms, last_synced_at_ms" +
|
||||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||||
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
||||||
"login = EXCLUDED.login, " +
|
"login = EXCLUDED.login, " +
|
||||||
|
"normalized_login = EXCLUDED.normalized_login, " +
|
||||||
"record_number = EXCLUDED.record_number, " +
|
"record_number = EXCLUDED.record_number, " +
|
||||||
"slot = EXCLUDED.slot, " +
|
"slot = EXCLUDED.slot, " +
|
||||||
"last_tx_signature = EXCLUDED.last_tx_signature, " +
|
"last_tx_signature = EXCLUDED.last_tx_signature, " +
|
||||||
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
|
|||||||
|
|
||||||
statement.setString(1, snapshot.pdaAddress());
|
statement.setString(1, snapshot.pdaAddress());
|
||||||
statement.setString(2, snapshot.login());
|
statement.setString(2, snapshot.login());
|
||||||
statement.setInt(3, snapshot.recordNumber());
|
statement.setString(3, normalizeLogin(snapshot.login()));
|
||||||
statement.setLong(4, snapshot.slot());
|
statement.setInt(4, snapshot.recordNumber());
|
||||||
statement.setString(5, snapshot.lastTxSignature());
|
statement.setLong(5, snapshot.slot());
|
||||||
statement.setString(6, snapshot.recoveryKey());
|
statement.setString(6, snapshot.lastTxSignature());
|
||||||
statement.setString(7, snapshot.rootKey());
|
statement.setString(7, snapshot.recoveryKey());
|
||||||
statement.setString(8, snapshot.clientKey());
|
statement.setString(8, snapshot.rootKey());
|
||||||
statement.setString(9, snapshot.blockchainName());
|
statement.setString(9, snapshot.clientKey());
|
||||||
statement.setString(10, snapshot.blockchainKey());
|
statement.setString(10, snapshot.blockchainName());
|
||||||
statement.setLong(11, snapshot.paidLimitBytes());
|
statement.setString(11, snapshot.blockchainKey());
|
||||||
statement.setLong(12, snapshot.usedBytes());
|
statement.setLong(12, snapshot.paidLimitBytes());
|
||||||
statement.setInt(13, snapshot.lastBlockNumber());
|
statement.setLong(13, snapshot.usedBytes());
|
||||||
statement.setString(14, snapshot.lastBlockHash());
|
statement.setInt(14, snapshot.lastBlockNumber());
|
||||||
statement.setString(15, snapshot.lastBlockSignature());
|
statement.setString(15, snapshot.lastBlockHash());
|
||||||
statement.setString(16, snapshot.arweaveTxId());
|
statement.setString(16, snapshot.lastBlockSignature());
|
||||||
statement.setBoolean(17, snapshot.isServer());
|
statement.setString(17, snapshot.arweaveTxId());
|
||||||
statement.setInt(18, snapshot.addressFormatType());
|
statement.setBoolean(18, snapshot.isServer());
|
||||||
statement.setInt(19, snapshot.addressFormatVersion());
|
statement.setInt(19, snapshot.addressFormatType());
|
||||||
statement.setString(20, snapshot.serverAddress());
|
statement.setInt(20, snapshot.addressFormatVersion());
|
||||||
statement.setString(21, writeJson(snapshot.syncServers()));
|
statement.setString(21, snapshot.serverAddress());
|
||||||
statement.setString(22, writeJson(snapshot.accessServers()));
|
statement.setString(22, writeJson(snapshot.syncServers()));
|
||||||
statement.setInt(23, snapshot.sessionsMode());
|
statement.setString(23, writeJson(snapshot.accessServers()));
|
||||||
statement.setString(24, writeJson(snapshot.sessions()));
|
statement.setInt(24, snapshot.sessionsMode());
|
||||||
statement.setInt(25, snapshot.trustedCount());
|
statement.setString(25, writeJson(snapshot.sessions()));
|
||||||
statement.setLong(26, snapshot.createdAtMs());
|
statement.setInt(26, snapshot.trustedCount());
|
||||||
statement.setLong(27, snapshot.updatedAtMs());
|
statement.setLong(27, snapshot.createdAtMs());
|
||||||
statement.setString(28, snapshot.prevRecordHash());
|
statement.setLong(28, snapshot.updatedAtMs());
|
||||||
statement.setString(29, snapshot.recordSignature());
|
statement.setString(29, snapshot.prevRecordHash());
|
||||||
statement.setString(30, snapshot.rawDataBase64());
|
statement.setString(30, snapshot.recordSignature());
|
||||||
statement.setLong(31, nowMs);
|
statement.setString(31, snapshot.rawDataBase64());
|
||||||
statement.setLong(32, nowMs);
|
statement.setLong(32, nowMs);
|
||||||
|
statement.setLong(33, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLogin(String login) {
|
||||||
|
return login == null ? "" : login.trim().toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
|
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
|
||||||
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
|
|||||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
|
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
|
||||||
"pda_address TEXT PRIMARY KEY, " +
|
"pda_address TEXT PRIMARY KEY, " +
|
||||||
"login TEXT NOT NULL UNIQUE, " +
|
"login TEXT NOT NULL UNIQUE, " +
|
||||||
|
"normalized_login TEXT NOT NULL, " +
|
||||||
"record_number INTEGER NOT NULL, " +
|
"record_number INTEGER NOT NULL, " +
|
||||||
"slot BIGINT NOT NULL, " +
|
"slot BIGINT NOT NULL, " +
|
||||||
"last_tx_signature TEXT NOT NULL, " +
|
"last_tx_signature TEXT NOT NULL, " +
|
||||||
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
|
|||||||
"last_synced_at_ms BIGINT NOT NULL" +
|
"last_synced_at_ms BIGINT NOT NULL" +
|
||||||
")"
|
")"
|
||||||
);
|
);
|
||||||
|
statement.executeUpdate(
|
||||||
|
"ALTER TABLE solana_user_pda_current " +
|
||||||
|
"ADD COLUMN IF NOT EXISTS normalized_login TEXT"
|
||||||
|
);
|
||||||
|
statement.executeUpdate(
|
||||||
|
"UPDATE solana_user_pda_current " +
|
||||||
|
"SET normalized_login = LOWER(BTRIM(login)) " +
|
||||||
|
"WHERE normalized_login IS NULL " +
|
||||||
|
" OR normalized_login <> LOWER(BTRIM(login))"
|
||||||
|
);
|
||||||
|
|
||||||
statement.executeUpdate(
|
statement.executeUpdate(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
|
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
|
||||||
"ON solana_user_pda_current(slot)"
|
"ON solana_user_pda_current(slot)"
|
||||||
);
|
);
|
||||||
|
statement.executeUpdate(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " +
|
||||||
|
"ON solana_user_pda_current(normalized_login)"
|
||||||
|
);
|
||||||
|
statement.executeUpdate(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " +
|
||||||
|
"ON solana_user_pda_current(normalized_login)"
|
||||||
|
);
|
||||||
|
|
||||||
statement.executeUpdate(
|
statement.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
|
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.6.0
|
client.version=1.7.0
|
||||||
server.version=1.5.0
|
server.version=1.6.0
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
- `user_access_servers_current`
|
- `user_access_servers_current`
|
||||||
- `solana_user_pda_history`
|
- `solana_user_pda_history`
|
||||||
- источник истины по пользовательским PDA: `solana_user_pda_current`.
|
- источник истины по пользовательским PDA: `solana_user_pda_current`.
|
||||||
|
- в `solana_user_pda_current` хранятся оба варианта логина:
|
||||||
|
- `login` — display-логин из PDA;
|
||||||
|
- `normalized_login` — канонический lower-case для runtime lookup и части FK;
|
||||||
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
|
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
|
||||||
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ psql \
|
|||||||
Скрипт:
|
Скрипт:
|
||||||
|
|
||||||
- создаёт таблицу версии схемы `db_schema_version`;
|
- создаёт таблицу версии схемы `db_schema_version`;
|
||||||
- ставит `schema_version = 2`;
|
- ставит актуальный `schema_version`;
|
||||||
- создаёт таблицы sync-модуля Solana users;
|
- создаёт таблицы sync-модуля Solana users;
|
||||||
- создаёт server runtime tables;
|
- создаёт server runtime tables;
|
||||||
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
||||||
|
|||||||
@@ -160,20 +160,20 @@
|
|||||||
|
|
||||||
### Вложения в сообщениях
|
### Вложения в сообщениях
|
||||||
|
|
||||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
|
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`.
|
||||||
|
|
||||||
Пример текстового содержимого body:
|
Пример текстового содержимого body:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||||
Текст сообщения
|
Текст сообщения
|
||||||
```
|
```
|
||||||
|
|
||||||
Пример вложения с отдельным preview-файлом:
|
Пример вложения с отдельным preview-файлом:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||||
@@ -183,8 +183,8 @@
|
|||||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Человекочитаемое имя канала>
|
<S:title;v=1;Человекочитаемое имя канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||||
Описание канала
|
Описание канала
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,12 +197,12 @@
|
|||||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Новое имя канала>
|
<S:title;v=1;Новое имя канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||||
Новое описание канала
|
Новое описание канала
|
||||||
```
|
```
|
||||||
|
|
||||||
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
|
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
|
||||||
|
|
||||||
## 7. Хватает ли функций сейчас
|
## 7. Хватает ли функций сейчас
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ Payload включает:
|
|||||||
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Название канала>
|
<S:title;v=1;Название канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||||
Описание канала.
|
Описание канала.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
7. `subType=90` — `TEXT_CHANNEL_META`
|
7. `subType=90` — `TEXT_CHANNEL_META`
|
||||||
- скрытый технический снимок профиля канала;
|
- скрытый технический снимок профиля канала;
|
||||||
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
- содержит line-поля + текст с тегами `S:title`/`S:ava` и описанием;
|
||||||
- не отображается как обычное сообщение ленты;
|
- не отображается как обычное сообщение ленты;
|
||||||
- применяется сервером к текущему состоянию канала.
|
- применяется сервером к текущему состоянию канала.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||||
|
|
||||||
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
|
## Вложения в текстовых сообщениях (`S:att v=1`)
|
||||||
|
|
||||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
||||||
|
|
||||||
@@ -84,31 +84,31 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
Один блок вложения:
|
Один блок вложения:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeee...>
|
||||||
```
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
||||||
|
|
||||||
Обязательные поля:
|
Обязательные поля:
|
||||||
|
|
||||||
- `v=1` или `v=2`
|
- `v=1`
|
||||||
- `name` - имя файла, закодированное через `encodeURIComponent`
|
- `nm` - имя файла, закодированное через `encodeURIComponent`
|
||||||
- `size` - размер файла в байтах
|
- `sz` - размер файла в байтах
|
||||||
- `sha256` - SHA-256 исходного файла в hex
|
- `sha256` - SHA-256 исходного файла в hex
|
||||||
- `ar` - короткий Arweave Transaction ID, без полного URL
|
- `ar` - короткий Arweave Transaction ID, без полного URL
|
||||||
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
|
- при наличии превью дополнительно могут присутствовать `preAr` и `preSha256`
|
||||||
|
|
||||||
Правила клиента:
|
Правила клиента:
|
||||||
|
|
||||||
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
|
- если сообщение начинается с одного или нескольких валидных attach-блоков (`<SHiNE:attach;...>`, `<S:attach;...>` или `<S:att;...>`), новый клиент скрывает эти блоки и показывает карточки вложений;
|
||||||
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
||||||
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
||||||
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
||||||
|
|
||||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
|
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `preAr/preSha256`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
|
# Вложения в TEXT-сообщениях (`S:att v=1`)
|
||||||
|
|
||||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||||
|
|
||||||
@@ -15,23 +15,23 @@
|
|||||||
|
|
||||||
## Общий вид
|
## Общий вид
|
||||||
|
|
||||||
Один attach-блок:
|
Канонический attach-блок:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Блок с превью:
|
Блок с превью:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
```
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста:
|
Несколько вложений идут подряд в самом начале текста:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||||
Текст сообщения
|
Текст сообщения
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,18 +41,18 @@
|
|||||||
|
|
||||||
## Поля
|
## Поля
|
||||||
|
|
||||||
Обязательные поля:
|
Обязательные поля канонического формата:
|
||||||
|
|
||||||
- `v=1` - версия формата attach-блока;
|
- `v=1` - версия формата attach-блока;
|
||||||
- `name` - имя файла, закодированное через `encodeURIComponent`;
|
- `nm` - имя файла, закодированное через `encodeURIComponent`;
|
||||||
- `size` - размер файла в байтах;
|
- `sz` - размер файла в байтах;
|
||||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||||
|
|
||||||
Дополнительные поля для `v=2`:
|
Дополнительные поля для превью:
|
||||||
|
|
||||||
- `previewAr` - короткий Arweave Transaction ID файла-превью;
|
- `preAr` - короткий Arweave Transaction ID файла-превью;
|
||||||
- `previewSha256` - SHA-256 файла-превью в hex.
|
- `preSha256` - SHA-256 файла-превью в hex.
|
||||||
|
|
||||||
## Хранение файла
|
## Хранение файла
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
- SHA-256;
|
- SHA-256;
|
||||||
- Arweave `txId`.
|
- Arweave `txId`.
|
||||||
|
|
||||||
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
|
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `preAr/preSha256`.
|
||||||
|
|
||||||
## Создание вложения в UI
|
## Создание вложения в UI
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ UI поддерживает три сценария:
|
|||||||
- основной видеофайл;
|
- основной видеофайл;
|
||||||
- отдельное изображение-превью.
|
- отдельное изображение-превью.
|
||||||
|
|
||||||
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
|
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `preAr/preSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||||
|
|
||||||
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ UI поддерживает три сценария:
|
|||||||
- изображение показывает как ограниченное по размеру превью;
|
- изображение показывает как ограниченное по размеру превью;
|
||||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||||
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
6. если у видео есть `preAr/preSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||||
7. при перелистывании останавливает воспроизводящееся видео;
|
7. при перелистывании останавливает воспроизводящееся видео;
|
||||||
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||||
|
|
||||||
@@ -118,7 +118,9 @@ UI поддерживает три сценария:
|
|||||||
|
|
||||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||||
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
|
- старые блоки `<SHiNE:attach ...>`, промежуточные `<S:attach ...>` и новые `<S:att ...>` читаются одинаково;
|
||||||
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
|
- старые поля `name/size/previewAr/previewSha256` и новые `nm/sz/preAr/preSha256` читаются одинаково;
|
||||||
|
- старые блоки `v=2` продолжают читаться как legacy-форма вложения с превью;
|
||||||
|
- `preAr/preSha256` используются только если присутствуют оба поля и оба валидны;
|
||||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
||||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||||
|
|||||||
@@ -18,15 +18,15 @@
|
|||||||
В начале текста могут идти технические теги, после них обычный текст описания:
|
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Название канала>
|
<S:title;v=1;Название канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||||
Описание канала.
|
Описание канала.
|
||||||
```
|
```
|
||||||
|
|
||||||
Поддерживаемые теги:
|
Поддерживаемые теги:
|
||||||
|
|
||||||
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
|
- `<S:title;v=1;...>` — человекочитаемое имя канала.
|
||||||
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
- `<S:ava;v=1;sz=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
- Длина описания — максимум 250 Unicode code points.
|
- Длина описания — максимум 250 Unicode code points.
|
||||||
- `avatar.ar` — Arweave transaction id из 43 символов.
|
- `avatar.ar` — Arweave transaction id из 43 символов.
|
||||||
- `avatar.sha256` — 64 hex-символа.
|
- `avatar.sha256` — 64 hex-символа.
|
||||||
- `avatar.size` — положительный размер файла в байтах.
|
- `avatar.sz` — положительный размер файла в байтах.
|
||||||
|
|
||||||
Если meta-блок невалиден, сервер не применяет его целиком.
|
Если meta-блок невалиден, сервер не применяет его целиком.
|
||||||
|
|
||||||
@@ -59,3 +59,9 @@
|
|||||||
## Замена старых команд
|
## Замена старых команд
|
||||||
|
|
||||||
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- Новый UI пишет сокращённые теги `<S:title ...>` и `<S:ava ...>`.
|
||||||
|
- Серверное чтение поддерживает и старые теги `<SHiNE:title ...>` / `<SHiNE:avatar ...>`.
|
||||||
|
- Для размера аватара чтение поддерживает и старое поле `size`, и новое поле `sz`.
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-08-12 13:00:00 +0400
|
||||||
|
- Базовый коммит-ориентир: `working tree`.
|
||||||
|
- Канонический текстовый формат служебных тегов сокращён:
|
||||||
|
- `TEXT_CHANNEL_META` теперь пишет `<S:title>` и `<S:ava>`;
|
||||||
|
- вложения теперь пишутся как `<S:att;v=1;nm=...;sz=...;sha256=...;ar=...>`;
|
||||||
|
- превью во вложениях задаётся полями `preAr/preSha256` без отдельной новой версии `v=2`;
|
||||||
|
- DM-вставки теперь пишутся с префиксом `<S:`.
|
||||||
|
- Зафиксирована обратная совместимость чтения:
|
||||||
|
- старые теги `<SHiNE:...>` продолжают поддерживаться;
|
||||||
|
- старые поля `name/size/previewAr/previewSha256` продолжают поддерживаться;
|
||||||
|
- legacy-вложения `v=2` продолжают читаться как вложения с превью.
|
||||||
|
|
||||||
## 2026-08-09 23:30:06 +0400
|
## 2026-08-09 23:30:06 +0400
|
||||||
- Базовый коммит-ориентир: `ee185cf`.
|
- Базовый коммит-ориентир: `ee185cf`.
|
||||||
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
|
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
||||||
Статусные действия пользователя (`msg_type=5`).
|
Статусные действия пользователя (`msg_type=5`).
|
||||||
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
|
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
|
||||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
|
Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений.
|
||||||
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||||
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
|
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
|
||||||
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
|
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
||||||
|
|
||||||
Исторический устаревший документ сохранён отдельно:
|
Исторический устаревший документ сохранён отдельно:
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
||||||
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
||||||
|
|
||||||
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
|
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
|
||||||
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
||||||
|
|
||||||
### 2.4. Источник истины по пользователю
|
### 2.4. Источник истины по пользователю
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Если в начале plaintext стоит один или несколько специальных блоков формата:
|
Если в начале plaintext стоит один или несколько специальных блоков формата:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:...>
|
<S:...>
|
||||||
```
|
```
|
||||||
|
|
||||||
то клиент трактует их как технические вставки.
|
то клиент трактует их как технические вставки.
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
Техническими считаются только блоки, которые:
|
Техническими считаются только блоки, которые:
|
||||||
|
|
||||||
- стоят строго в начале plaintext;
|
- стоят строго в начале plaintext;
|
||||||
- начинаются с точного префикса `<SHiNE:`;
|
- начинаются с префикса `<S:` или legacy-префикса `<SHiNE:`;
|
||||||
- заканчиваются первым символом `>`.
|
- заканчиваются первым символом `>`.
|
||||||
|
|
||||||
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
|
Если текст не начинается с `<S:` или `<SHiNE:`, никакие технические вставки не ищутся.
|
||||||
|
|
||||||
## 2. Правило скрытия
|
## 2. Правило скрытия
|
||||||
|
|
||||||
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
|
Все корректно распознанные блоки `<S:...>` или `<SHiNE:...>` в начале plaintext:
|
||||||
|
|
||||||
- не показываются пользователю как сырой текст;
|
- не показываются пользователю как сырой текст;
|
||||||
- используются клиентом для UI-логики;
|
- используются клиентом для UI-логики;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
Перед отправкой обычного текстового сообщения клиент обязан проверить:
|
Перед отправкой обычного текстового сообщения клиент обязан проверить:
|
||||||
|
|
||||||
- если пользовательский текст начинается с `<SHiNE:`
|
- если пользовательский текст начинается с `<S:` или `<SHiNE:`
|
||||||
|
|
||||||
то клиент автоматически превращает начало в:
|
то клиент автоматически превращает начало в:
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
Общий вид:
|
Общий вид:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:kind;key=value;key=value;...>
|
<S:kind;key=value;key=value;...>
|
||||||
```
|
```
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
@@ -69,14 +69,15 @@
|
|||||||
- параметры отделяются `;`;
|
- параметры отделяются `;`;
|
||||||
- ключ и значение отделяются `=`;
|
- ключ и значение отделяются `=`;
|
||||||
- значения не экранируются в v1;
|
- значения не экранируются в v1;
|
||||||
- формат чувствителен к точному префиксу `<SHiNE:`.
|
- канонический новый префикс: `<S:`;
|
||||||
|
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
|
||||||
|
|
||||||
## 5. Тип `reply`
|
## 5. Тип `reply`
|
||||||
|
|
||||||
Формат:
|
Формат:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
<S:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
||||||
```
|
```
|
||||||
|
|
||||||
Где поле `id` — это логический идентификатор сообщения:
|
Где поле `id` — это логический идентификатор сообщения:
|
||||||
@@ -91,14 +92,14 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
- после него может идти обычный текст ответа;
|
- после него может идти обычный текст ответа;
|
||||||
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
||||||
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
||||||
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
|
- в таком случае само сообщение отображается как обычный текст без блока `<S:reply...>`.
|
||||||
|
|
||||||
## 6. Тип `call`
|
## 6. Тип `call`
|
||||||
|
|
||||||
### Успешный звонок
|
### Успешный звонок
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:call;v=1;status=completed;duration=367>
|
<S:call;v=1;status=completed;duration=367>
|
||||||
```
|
```
|
||||||
|
|
||||||
Где:
|
Где:
|
||||||
@@ -108,7 +109,7 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
### Неуспешный звонок
|
### Неуспешный звонок
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:call;v=1;status=failed;reason=offline>
|
<S:call;v=1;status=failed;reason=offline>
|
||||||
```
|
```
|
||||||
|
|
||||||
Допустимые причины в v1:
|
Допустимые причины в v1:
|
||||||
@@ -130,7 +131,7 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
|
|
||||||
Официальный UI SHiNE в v1:
|
Официальный UI SHiNE в v1:
|
||||||
|
|
||||||
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
|
- скрывает все корректные `<S:...>` и legacy `<SHiNE:...>` блоки в начале plaintext;
|
||||||
- для `call` строит специальный человекочитаемый текст:
|
- для `call` строит специальный человекочитаемый текст:
|
||||||
- `Звонок: M:SS`
|
- `Звонок: M:SS`
|
||||||
- `Звонок: H:MM:SS`
|
- `Звонок: H:MM:SS`
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ Append-only журнал всех просмотренных транзакци
|
|||||||
|
|
||||||
- `pda_address TEXT PRIMARY KEY`
|
- `pda_address TEXT PRIMARY KEY`
|
||||||
- `login TEXT NOT NULL`
|
- `login TEXT NOT NULL`
|
||||||
|
- `normalized_login TEXT NOT NULL`
|
||||||
- `record_number INTEGER NOT NULL`
|
- `record_number INTEGER NOT NULL`
|
||||||
- `slot INTEGER NOT NULL`
|
- `slot INTEGER NOT NULL`
|
||||||
- `last_tx_signature TEXT NOT NULL`
|
- `last_tx_signature TEXT NOT NULL`
|
||||||
@@ -349,9 +350,16 @@ Append-only журнал всех просмотренных транзакци
|
|||||||
Индексы:
|
Индексы:
|
||||||
|
|
||||||
- уникальный индекс на `login`
|
- уникальный индекс на `login`
|
||||||
|
- уникальный индекс на `normalized_login`
|
||||||
- индекс на `slot`
|
- индекс на `slot`
|
||||||
- индекс на `last_tx_signature`
|
- индекс на `last_tx_signature`
|
||||||
|
|
||||||
|
Правило использования:
|
||||||
|
|
||||||
|
- `login` хранит display-логин ровно в том регистре, как он записан в PDA;
|
||||||
|
- `normalized_login` хранит канонический lower-case логин;
|
||||||
|
- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case.
|
||||||
|
|
||||||
### 4. `solana_user_pda_history`
|
### 4. `solana_user_pda_history`
|
||||||
|
|
||||||
Append-only история всех версий пользовательских PDA.
|
Append-only история всех версий пользовательских PDA.
|
||||||
|
|||||||
+21
-3
@@ -1,4 +1,9 @@
|
|||||||
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
|
import {
|
||||||
|
navigate,
|
||||||
|
getRoute,
|
||||||
|
PRE_AUTH_PAGES,
|
||||||
|
syncTrackedRouteHistory,
|
||||||
|
} from './router.js';
|
||||||
import { renderToolbar } from './components/toolbar.js';
|
import { renderToolbar } from './components/toolbar.js';
|
||||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||||
@@ -283,6 +288,13 @@ function createChromeController(showAppChrome) {
|
|||||||
composerNode = null;
|
composerNode = null;
|
||||||
apply();
|
apply();
|
||||||
},
|
},
|
||||||
|
suspend() {
|
||||||
|
clearSlot(topbarEl, '--topbar-height');
|
||||||
|
clearSlot(composerEl, '--composer-height');
|
||||||
|
},
|
||||||
|
resume() {
|
||||||
|
apply();
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearSlot(topbarEl, '--topbar-height');
|
clearSlot(topbarEl, '--topbar-height');
|
||||||
@@ -291,6 +303,11 @@ function createChromeController(showAppChrome) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearKeepAliveEntries() {
|
||||||
|
currentCleanup = null;
|
||||||
|
currentChromeCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function unlockHiddenDmAudio() {
|
async function unlockHiddenDmAudio() {
|
||||||
try {
|
try {
|
||||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||||
@@ -1007,6 +1024,7 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
|
||||||
@@ -1021,7 +1039,7 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page = routes[pageId] || routes['start-view'];
|
const page = routes[pageId] || routes['start-view'];
|
||||||
|
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||||
if (typeof currentCleanup === 'function') {
|
if (typeof currentCleanup === 'function') {
|
||||||
currentCleanup();
|
currentCleanup();
|
||||||
currentCleanup = null;
|
currentCleanup = null;
|
||||||
@@ -1033,7 +1051,6 @@ function renderApp() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
screenEl.innerHTML = '';
|
screenEl.innerHTML = '';
|
||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
|
||||||
const chrome = createChromeController(showAppChrome);
|
const chrome = createChromeController(showAppChrome);
|
||||||
currentChromeCleanup = () => chrome.dispose();
|
currentChromeCleanup = () => chrome.dispose();
|
||||||
const screen = page.render({ route, navigate, chrome });
|
const screen = page.render({ route, navigate, chrome });
|
||||||
@@ -1163,6 +1180,7 @@ async function init() {
|
|||||||
setSessionResetHandler(() => {
|
setSessionResetHandler(() => {
|
||||||
sessionRuntimeStarted = false;
|
sessionRuntimeStarted = false;
|
||||||
startConnectionMonitor();
|
startConnectionMonitor();
|
||||||
|
clearKeepAliveEntries();
|
||||||
if (reconnectIntervalId) {
|
if (reconnectIntervalId) {
|
||||||
window.clearInterval(reconnectIntervalId);
|
window.clearInterval(reconnectIntervalId);
|
||||||
reconnectIntervalId = null;
|
reconnectIntervalId = null;
|
||||||
|
|||||||
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
|
|||||||
onSelect,
|
onSelect,
|
||||||
selectedTxIds = [],
|
selectedTxIds = [],
|
||||||
historyOnly = false,
|
historyOnly = false,
|
||||||
|
persistToHistory = true,
|
||||||
|
allowHistorySelection = true,
|
||||||
|
allowExistingTxInput = true,
|
||||||
mode = 'attachment',
|
mode = 'attachment',
|
||||||
historyPurpose = '',
|
historyPurpose = '',
|
||||||
uploadTransport = 'turbo',
|
uploadTransport = 'turbo',
|
||||||
turboKeySource = 'client',
|
turboKeySource = 'client',
|
||||||
|
dialogTitle = '',
|
||||||
|
uploadButtonLabel = '',
|
||||||
|
initialFile = null,
|
||||||
|
initialSha256 = '',
|
||||||
|
initialName = '',
|
||||||
|
fixedFile = false,
|
||||||
|
autoOpenFileDialog = true,
|
||||||
|
shineType = '',
|
||||||
|
extraUploadTags = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||||
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
|
|||||||
let selectedPreviewPriceInfo = null;
|
let selectedPreviewPriceInfo = null;
|
||||||
let priceInfo = null;
|
let priceInfo = null;
|
||||||
let balanceInfo = null;
|
let balanceInfo = null;
|
||||||
let autoOpenedFileDialog = false;
|
let autoOpenedFileDialogOnce = false;
|
||||||
const isAvatarMode = String(mode || '') === 'avatar';
|
const isAvatarMode = String(mode || '') === 'avatar';
|
||||||
if (isAvatarMode && !String(uploadTransport || '').trim()) {
|
if (isAvatarMode && !String(uploadTransport || '').trim()) {
|
||||||
selectedUploadTransport = 'turbo';
|
selectedUploadTransport = 'turbo';
|
||||||
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
|
|||||||
const historyPurposeMode = String(historyPurpose || '').trim();
|
const historyPurposeMode = String(historyPurpose || '').trim();
|
||||||
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
||||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||||
|
const effectiveDialogTitle = String(dialogTitle || '').trim();
|
||||||
|
const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim();
|
||||||
|
const forcedShineType = String(shineType || '').trim();
|
||||||
|
const normalizedExtraUploadTags = Array.isArray(extraUploadTags)
|
||||||
|
? extraUploadTags.filter((item) => item?.name && item?.value)
|
||||||
|
: [];
|
||||||
|
if (initialFile instanceof File) {
|
||||||
|
selectedFile = initialFile;
|
||||||
|
if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function isTurboUpload() {
|
function isTurboUpload() {
|
||||||
return selectedUploadTransport === 'turbo';
|
return selectedUploadTransport === 'turbo';
|
||||||
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||||
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
const item = persistToHistory
|
||||||
|
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||||
pendingPlacement,
|
pendingPlacement,
|
||||||
markPlaced: false,
|
markPlaced: false,
|
||||||
});
|
})
|
||||||
|
: {
|
||||||
|
...attachment,
|
||||||
|
...normalizeAttachment(attachment),
|
||||||
|
};
|
||||||
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
||||||
close(resolve, item);
|
close(resolve, item);
|
||||||
}
|
}
|
||||||
@@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({
|
|||||||
|
|
||||||
const showUpload = async () => {
|
const showUpload = async () => {
|
||||||
const turboMode = isTurboUpload();
|
const turboMode = isTurboUpload();
|
||||||
|
const titleText = effectiveDialogTitle
|
||||||
|
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|
||||||
|
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
||||||
|
const canShowHistory = allowHistorySelection;
|
||||||
|
const canShowExisting = allowExistingTxInput;
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" data-ar-attach-modal="true">
|
<div class="modal" data-ar-attach-modal="true">
|
||||||
<div class="modal-card stack ar-attachment-manager-card">
|
<div class="modal-card stack ar-attachment-manager-card">
|
||||||
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||||
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
|
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||||
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
|
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${turboMode
|
${turboMode
|
||||||
? `
|
? `
|
||||||
@@ -611,8 +643,8 @@ export function openArweaveAttachmentManager({
|
|||||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||||
`}
|
`}
|
||||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||||
<label class="meta-muted" data-preview-option="true" hidden>
|
<label class="meta-muted" data-preview-option="true" hidden>
|
||||||
<input type="checkbox" data-preview-toggle="true" />
|
<input type="checkbox" data-preview-toggle="true" />
|
||||||
@@ -622,7 +654,7 @@ export function openArweaveAttachmentManager({
|
|||||||
<p class="meta-muted inline-error" data-error="true"></p>
|
<p class="meta-muted inline-error" data-error="true"></p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -818,27 +850,30 @@ export function openArweaveAttachmentManager({
|
|||||||
storagePwd: cleanStoragePwd,
|
storagePwd: cleanStoragePwd,
|
||||||
keySource: selectedTurboKeySource,
|
keySource: selectedTurboKeySource,
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||||
|
...normalizedExtraUploadTags,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
: await uploadArweaveFile({
|
: await uploadArweaveFile({
|
||||||
gateway: cleanGateway,
|
gateway: cleanGateway,
|
||||||
jwk: selectedWallet()?.jwk,
|
jwk: selectedWallet()?.jwk,
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||||
|
...normalizedExtraUploadTags,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
finish(resolve, {
|
finish(resolve, {
|
||||||
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
|
||||||
size: selectedFile.size,
|
size: selectedFile.size,
|
||||||
sha256: selectedSha256,
|
sha256: selectedSha256,
|
||||||
ar: uploaded.id,
|
ar: uploaded.id,
|
||||||
|
uploadTransport: turboMode ? 'turbo' : 'arweave',
|
||||||
preview: previewUpload?.id && selectedPreviewSha256
|
preview: previewUpload?.id && selectedPreviewSha256
|
||||||
? {
|
? {
|
||||||
ar: previewUpload.id,
|
ar: previewUpload.id,
|
||||||
@@ -854,8 +889,19 @@ export function openArweaveAttachmentManager({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!autoOpenedFileDialog) {
|
if (selectedFile && selectedSha256) {
|
||||||
autoOpenedFileDialog = true;
|
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
|
||||||
|
previewOptionEl.hidden = false;
|
||||||
|
}
|
||||||
|
if (turboMode) {
|
||||||
|
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||||
|
} else {
|
||||||
|
await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fixedFile && autoOpenFileDialog && !autoOpenedFileDialogOnce) {
|
||||||
|
autoOpenedFileDialogOnce = true;
|
||||||
window.setTimeout(() => fileEl?.click(), 0);
|
window.setTimeout(() => fileEl?.click(), 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -332,6 +332,25 @@ function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveThreadBackRoute(selector, resolvedChannelLabel = '') {
|
||||||
|
return buildChannelRouteFromThread(selector, resolvedChannelLabel) || 'channels-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThreadPreviousInChannels(selector, resolvedChannelLabel = '') {
|
||||||
|
const previousPath = String(getPreviousTrackedPath() || '').trim();
|
||||||
|
if (previousPath) {
|
||||||
|
const previousRoute = parseRouteFromPath(previousPath);
|
||||||
|
const previousPageId = String(previousRoute?.pageId || '').trim();
|
||||||
|
if (
|
||||||
|
previousPageId === 'channel-thread-view'
|
||||||
|
|| previousPageId === 'channel-view'
|
||||||
|
) {
|
||||||
|
return previousPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolveThreadBackRoute(selector, resolvedChannelLabel);
|
||||||
|
}
|
||||||
|
|
||||||
function buildTargetFromNode(node) {
|
function buildTargetFromNode(node) {
|
||||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||||
@@ -1118,6 +1137,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const selector = parseThreadSelector(route);
|
const selector = parseThreadSelector(route);
|
||||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||||
|
let activeResolvedChannelLabel = channelDisplayName;
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--thread';
|
screen.className = 'stack channels-screen channels-screen--thread';
|
||||||
@@ -1126,13 +1146,28 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||||
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
|
rightActions: [],
|
||||||
});
|
});
|
||||||
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
|
header.classList.add('channel-thread-topbar');
|
||||||
if (threadHeaderButton) {
|
const headerLeft = header.querySelector('.header-left');
|
||||||
threadHeaderButton.classList.add('channel-header-route-btn');
|
let threadHeaderButton = null;
|
||||||
|
if (headerLeft) {
|
||||||
|
const channelsListButton = document.createElement('button');
|
||||||
|
channelsListButton.type = 'button';
|
||||||
|
channelsListButton.className = 'icon-btn';
|
||||||
|
channelsListButton.textContent = '↑';
|
||||||
|
channelsListButton.title = 'К списку каналов';
|
||||||
|
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
||||||
|
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
headerLeft.append(channelsListButton);
|
||||||
|
|
||||||
|
threadHeaderButton = document.createElement('button');
|
||||||
|
threadHeaderButton.type = 'button';
|
||||||
|
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
||||||
|
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||||
threadHeaderButton.disabled = true;
|
threadHeaderButton.disabled = true;
|
||||||
|
headerLeft.append(threadHeaderButton);
|
||||||
}
|
}
|
||||||
chrome?.setTopbar(header);
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
@@ -1418,6 +1453,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||||
}
|
}
|
||||||
|
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||||
if (threadHeaderButton) {
|
if (threadHeaderButton) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { navigateBack } from '../router.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -2036,7 +2035,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
|||||||
|
|
||||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||||
|
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
|
||||||
const CHANNEL_TYPE_STORIES = 0;
|
const CHANNEL_TYPE_STORIES = 0;
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const DIARY_CHANNEL_NAME = 'diary';
|
const DIARY_CHANNEL_NAME = 'diary';
|
||||||
const DIARY_DISPLAY_NAME = 'Дневник';
|
const DIARY_DISPLAY_NAME = 'Дневник';
|
||||||
|
|
||||||
|
const CHANNELS_VIEW_ALL = 'all';
|
||||||
|
const CHANNELS_VIEW_OWNED = 'owned';
|
||||||
|
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||||
|
|
||||||
function cleanChannelMessagePreview(text) {
|
function cleanChannelMessagePreview(text) {
|
||||||
const parsed = parseMessageAttachments(text);
|
const parsed = parseMessageAttachments(text);
|
||||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||||
@@ -228,6 +233,26 @@ function normalizeComparableLogin(value) {
|
|||||||
return normalizeLoginInput(value).toLowerCase();
|
return normalizeLoginInput(value).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChannelsViewMode(route = null) {
|
||||||
|
const mode = String(route?.params?.mode || '').trim().toLowerCase();
|
||||||
|
const scope = String(route?.params?.scope || '').trim().toLowerCase();
|
||||||
|
if (mode === 'my' || scope === 'owned') return CHANNELS_VIEW_OWNED;
|
||||||
|
if (mode === 'following' || scope === 'following') return CHANNELS_VIEW_FOLLOWING;
|
||||||
|
return CHANNELS_VIEW_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelsViewRoute(mode) {
|
||||||
|
if (mode === CHANNELS_VIEW_OWNED) return 'channels/my';
|
||||||
|
if (mode === CHANNELS_VIEW_FOLLOWING) return 'channels/following';
|
||||||
|
return 'channels';
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelsViewTitle(mode) {
|
||||||
|
if (mode === CHANNELS_VIEW_OWNED) return 'Мои каналы';
|
||||||
|
if (mode === CHANNELS_VIEW_FOLLOWING) return 'Подписки';
|
||||||
|
return 'Каналы';
|
||||||
|
}
|
||||||
|
|
||||||
function isFollowedUserVisible(targetLogin) {
|
function isFollowedUserVisible(targetLogin) {
|
||||||
const expected = normalizeComparableLogin(targetLogin);
|
const expected = normalizeComparableLogin(targetLogin);
|
||||||
if (!expected) return false;
|
if (!expected) return false;
|
||||||
@@ -617,6 +642,11 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
function mapMockGroups() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
|
sourceBucket: channel.kind === 'subscribed'
|
||||||
|
? 'followedChannels'
|
||||||
|
: channel.kind === 'followed-user-channel'
|
||||||
|
? 'followedUsers'
|
||||||
|
: 'own',
|
||||||
route: makeShineChannelRoute({
|
route: makeShineChannelRoute({
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
ownerLogin: String(channel.ownerName || 'channel'),
|
||||||
ownerBlockchainName: String(channel.ownerName || ''),
|
ownerBlockchainName: String(channel.ownerName || ''),
|
||||||
@@ -668,6 +698,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
|
sourceBucket: bucketKey,
|
||||||
route: buildChannelRouteFromSummary(summary, rowId),
|
route: buildChannelRouteFromSummary(summary, rowId),
|
||||||
ownerName: ownerLogin,
|
ownerName: ownerLogin,
|
||||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||||
@@ -721,6 +752,7 @@ function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
|
sourceBucket: 'own',
|
||||||
route: makeShineChannelRoute({
|
route: makeShineChannelRoute({
|
||||||
ownerLogin,
|
ownerLogin,
|
||||||
ownerBlockchainName,
|
ownerBlockchainName,
|
||||||
@@ -891,6 +923,98 @@ function closeChannelMenu(listState, clearOpenMenuId = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTopChannelsMenu(listState) {
|
||||||
|
if (typeof listState.topMenuCleanup === 'function') {
|
||||||
|
listState.topMenuCleanup();
|
||||||
|
}
|
||||||
|
listState.topMenuCleanup = null;
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (root) {
|
||||||
|
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTopChannelsMenu({
|
||||||
|
listState,
|
||||||
|
anchorEl,
|
||||||
|
navigate,
|
||||||
|
onSubscribeChannel,
|
||||||
|
onFindChannel,
|
||||||
|
}) {
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root || !anchorEl) return;
|
||||||
|
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
|
||||||
|
let left = rect.right - menuWidth;
|
||||||
|
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||||
|
|
||||||
|
const estimatedHeight = 320;
|
||||||
|
let top = rect.bottom + 8;
|
||||||
|
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||||
|
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = TOP_MENU_OVERLAY_ID;
|
||||||
|
overlay.className = 'channels-menu-overlay';
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||||
|
menu.style.left = `${Math.round(left)}px`;
|
||||||
|
menu.style.top = `${Math.round(top)}px`;
|
||||||
|
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||||
|
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||||
|
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
||||||
|
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||||
|
];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.divider) {
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'channel-menu-divider';
|
||||||
|
divider.style.height = '1px';
|
||||||
|
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
divider.style.margin = '6px 0';
|
||||||
|
menu.append(divider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'channel-menu-item';
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
|
item.action?.();
|
||||||
|
});
|
||||||
|
menu.append(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.append(menu);
|
||||||
|
root.append(overlay);
|
||||||
|
|
||||||
|
const onOverlayClick = (event) => {
|
||||||
|
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||||
|
};
|
||||||
|
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', onOverlayClick);
|
||||||
|
window.addEventListener('resize', onWindowResize);
|
||||||
|
listState.topMenuCleanup = () => {
|
||||||
|
overlay.removeEventListener('click', onOverlayClick);
|
||||||
|
window.removeEventListener('resize', onWindowResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||||||
closeChannelMenu(listState, false);
|
closeChannelMenu(listState, false);
|
||||||
|
|
||||||
@@ -1060,7 +1184,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const allChannels = listState.channels || [];
|
const allChannels = listState.channels || [];
|
||||||
const filtered = allChannels;
|
const filtered = allChannels.filter((channel) => {
|
||||||
|
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
|
||||||
|
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
container.append(renderEmptyState());
|
container.append(renderEmptyState());
|
||||||
@@ -1228,10 +1356,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const isGuest = !state.session.isAuthorized;
|
const isGuest = !state.session.isAuthorized;
|
||||||
const listState = {
|
const listState = {
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
|
topMenuCleanup: null,
|
||||||
notificationsState,
|
notificationsState,
|
||||||
revealedCounters: new Set(),
|
revealedCounters: new Set(),
|
||||||
channels: [],
|
channels: [],
|
||||||
menuCleanup: null,
|
menuCleanup: null,
|
||||||
|
viewMode: normalizeChannelsViewMode(route),
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentEl = document.createElement('div');
|
const contentEl = document.createElement('div');
|
||||||
@@ -1274,8 +1404,31 @@ export function render({ navigate, route, chrome }) {
|
|||||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
|
||||||
|
const topMenuBtn = document.createElement('button');
|
||||||
|
topMenuBtn.type = 'button';
|
||||||
|
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||||
|
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||||
|
topMenuBtn.title = 'Ещё действия';
|
||||||
|
topMenuBtn.textContent = '⋮';
|
||||||
|
topMenuBtn.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(topMenuBtn);
|
||||||
|
openTopChannelsMenu({
|
||||||
|
listState,
|
||||||
|
anchorEl: topMenuBtn,
|
||||||
|
navigate,
|
||||||
|
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||||
|
onSubscribeChannel: () => openSimpleSubscribeModal({
|
||||||
|
kind: 'channel',
|
||||||
|
kindLabel: 'Добавить канал',
|
||||||
|
submitLabel: 'Добавить',
|
||||||
|
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
topBarLeft.append(backBtn, topTitle);
|
topBarLeft.append(backBtn, topTitle);
|
||||||
topBarRight.append(findChannelBtn, createInMyBtn);
|
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
@@ -1284,18 +1437,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
try {
|
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||||
const expectedPath = '/channels';
|
|
||||||
if (window.location.pathname !== expectedPath) {
|
|
||||||
window.history.replaceState({}, '', expectedPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore history errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTabEmpty = !(listState.channels || []).length;
|
|
||||||
|
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
|
|
||||||
renderListContent({
|
renderListContent({
|
||||||
screen,
|
screen,
|
||||||
@@ -1305,9 +1449,10 @@ export function render({ navigate, route, chrome }) {
|
|||||||
refreshFeed: reloadFeed,
|
refreshFeed: reloadFeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
topTitle.textContent = 'Каналы';
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
findChannelBtn.style.display = '';
|
findChannelBtn.style.display = '';
|
||||||
createInMyBtn.style.display = '';
|
createInMyBtn.style.display = '';
|
||||||
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
updateBottomCta({ button: bottomCta });
|
||||||
@@ -1329,6 +1474,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ function renderList(container) {
|
|||||||
container.append(card);
|
container.append(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render() {
|
export function render({ chrome } = {}) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack notifications-screen';
|
screen.className = 'stack notifications-screen';
|
||||||
|
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||||
screen.append(renderHeader({ title: 'Уведомления' }));
|
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'tabs';
|
tabs.className = 'tabs';
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
} from '../services/arweave-wallet-service.js';
|
} from '../services/arweave-wallet-service.js';
|
||||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
|
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
|
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
|
||||||
import {
|
import {
|
||||||
calcLimitTopupPriceLamports,
|
calcLimitTopupPriceLamports,
|
||||||
getLimitStepBytes,
|
getLimitStepBytes,
|
||||||
@@ -24,6 +26,12 @@ import {
|
|||||||
getShineUsersEconomyConfig,
|
getShineUsersEconomyConfig,
|
||||||
updateShineUserPdaOnSolana,
|
updateShineUserPdaOnSolana,
|
||||||
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
|
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
|
||||||
|
import {
|
||||||
|
buildBlockchainSnapshotFile,
|
||||||
|
clearStoredBlockchainSnapshot,
|
||||||
|
readStoredBlockchainSnapshot,
|
||||||
|
saveStoredBlockchainSnapshot,
|
||||||
|
} from '../services/shine-blockchain-snapshot-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||||
|
|
||||||
@@ -40,6 +48,17 @@ function formatKbFromBytes(rawBytes) {
|
|||||||
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
|
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytesRu(rawBytes) {
|
||||||
|
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||||
|
return bytes.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHex64(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
|
||||||
|
}
|
||||||
|
|
||||||
function lamportsToSolText(lamportsBigInt) {
|
function lamportsToSolText(lamportsBigInt) {
|
||||||
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
|
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
|
||||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
|
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
|
||||||
@@ -461,6 +480,55 @@ export function render({ navigate }) {
|
|||||||
arweaveWalletCtx = null;
|
arweaveWalletCtx = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchServerBlockchainState() {
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
const user = await authService.getUser(login);
|
||||||
|
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
|
||||||
|
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||||||
|
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||||||
|
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
|
||||||
|
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWalletSigningMaterial() {
|
||||||
|
const { login, storagePwd } = sessionArgsOrThrow();
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||||||
|
} catch {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
let rootKey = String(saved?.rootKey || '').trim();
|
||||||
|
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||||||
|
const clientKey = String(saved?.clientKey || '').trim();
|
||||||
|
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
|
||||||
|
if (rootKey && blockchainKey) {
|
||||||
|
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = window.prompt(
|
||||||
|
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (password == null) throw new Error('Операция отменена пользователем');
|
||||||
|
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||||
|
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||||||
|
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||||||
|
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||||||
|
|
||||||
|
const shouldSave = window.confirm(
|
||||||
|
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||||||
|
);
|
||||||
|
if (shouldSave) {
|
||||||
|
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||||||
|
}
|
||||||
|
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||||
|
}
|
||||||
|
|
||||||
function styleSupportInputField(field) {
|
function styleSupportInputField(field) {
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
field.style.color = '#111111';
|
field.style.color = '#111111';
|
||||||
@@ -1245,6 +1313,14 @@ export function render({ navigate }) {
|
|||||||
void renderShineBlockchainWallet();
|
void renderShineBlockchainWallet();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const solanaPublishBtn = document.createElement('button');
|
||||||
|
solanaPublishBtn.className = 'primary-btn';
|
||||||
|
solanaPublishBtn.style.width = '100%';
|
||||||
|
solanaPublishBtn.textContent = 'Закрепление в Solana';
|
||||||
|
solanaPublishBtn.addEventListener('click', () => {
|
||||||
|
void renderSolanaPublishWallet();
|
||||||
|
});
|
||||||
|
|
||||||
const supportBtn = document.createElement('button');
|
const supportBtn = document.createElement('button');
|
||||||
supportBtn.className = 'primary-btn';
|
supportBtn.className = 'primary-btn';
|
||||||
supportBtn.style.width = '100%';
|
supportBtn.style.width = '100%';
|
||||||
@@ -1253,7 +1329,7 @@ export function render({ navigate }) {
|
|||||||
void renderSupportHub();
|
void renderSupportHub();
|
||||||
});
|
});
|
||||||
|
|
||||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
|
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
|
||||||
content.append(card);
|
content.append(card);
|
||||||
setStatus('Выберите тип кошелька.');
|
setStatus('Выберите тип кошелька.');
|
||||||
}
|
}
|
||||||
@@ -1339,63 +1415,13 @@ export function render({ navigate }) {
|
|||||||
actions.className = 'stack';
|
actions.className = 'stack';
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
||||||
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
|
|
||||||
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
||||||
`;
|
`;
|
||||||
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
||||||
const syncBtn = actions.querySelector('#sync-shine-solana');
|
|
||||||
const topupBtn = actions.querySelector('#topup-shine-limit');
|
const topupBtn = actions.querySelector('#topup-shine-limit');
|
||||||
|
|
||||||
const fetchServerState = async () => {
|
|
||||||
const user = await authService.getUser(String(state.session.login || '').trim());
|
|
||||||
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
|
||||||
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
|
|
||||||
return {
|
|
||||||
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
|
||||||
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
|
||||||
lastNumber,
|
|
||||||
lastHash: String(user.serverLastGlobalHash || ''),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveWalletSigningMaterial = async () => {
|
|
||||||
const { login, storagePwd } = sessionArgsOrThrow();
|
|
||||||
let saved;
|
|
||||||
try {
|
|
||||||
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
|
||||||
} catch {
|
|
||||||
saved = null;
|
|
||||||
}
|
|
||||||
let rootKey = String(saved?.rootKey || '').trim();
|
|
||||||
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
|
||||||
const clientKey = String(saved?.clientKey || '').trim();
|
|
||||||
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
|
|
||||||
if (rootKey && blockchainKey) {
|
|
||||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = window.prompt(
|
|
||||||
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
if (password == null) throw new Error('Операция отменена пользователем');
|
|
||||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
|
||||||
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
|
||||||
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
|
||||||
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
|
||||||
|
|
||||||
const shouldSave = window.confirm(
|
|
||||||
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
|
||||||
);
|
|
||||||
if (shouldSave) {
|
|
||||||
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
|
||||||
}
|
|
||||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
|
||||||
};
|
|
||||||
|
|
||||||
const setButtonsDisabled = (disabled) => {
|
const setButtonsDisabled = (disabled) => {
|
||||||
refreshBtn.disabled = disabled;
|
refreshBtn.disabled = disabled;
|
||||||
syncBtn.disabled = disabled;
|
|
||||||
topupBtn.disabled = disabled;
|
topupBtn.disabled = disabled;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1407,7 +1433,7 @@ export function render({ navigate }) {
|
|||||||
login: String(state.session.login || '').trim(),
|
login: String(state.session.login || '').trim(),
|
||||||
solanaEndpoint: state.entrySettings.solanaServer,
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
}),
|
}),
|
||||||
fetchServerState(),
|
fetchServerBlockchainState(),
|
||||||
]);
|
]);
|
||||||
if (modeToken !== activeModeToken) return;
|
if (modeToken !== activeModeToken) return;
|
||||||
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
||||||
@@ -1423,9 +1449,6 @@ export function render({ navigate }) {
|
|||||||
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||||
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||||
|
|
||||||
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
|
||||||
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
|
||||||
|
|
||||||
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (modeToken !== activeModeToken) return;
|
if (modeToken !== activeModeToken) return;
|
||||||
@@ -1435,35 +1458,6 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncBtn.addEventListener('click', async () => {
|
|
||||||
setButtonsDisabled(true);
|
|
||||||
try {
|
|
||||||
const [serverState, signing] = await Promise.all([
|
|
||||||
fetchServerState(),
|
|
||||||
resolveWalletSigningMaterial(),
|
|
||||||
]);
|
|
||||||
const result = await updateShineUserPdaOnSolana({
|
|
||||||
login: String(state.session.login || '').trim(),
|
|
||||||
solanaEndpoint: state.entrySettings.solanaServer,
|
|
||||||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
|
||||||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
|
||||||
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
|
||||||
additionalLimitBytes: 0n,
|
|
||||||
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
|
||||||
nextLastBlockNumber: serverState.lastNumber,
|
|
||||||
nextLastBlockHashHex: serverState.lastHash,
|
|
||||||
});
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
|
|
||||||
await refreshUsage();
|
|
||||||
} catch (error) {
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
|
|
||||||
} finally {
|
|
||||||
setButtonsDisabled(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
topupBtn.addEventListener('click', async () => {
|
topupBtn.addEventListener('click', async () => {
|
||||||
setButtonsDisabled(true);
|
setButtonsDisabled(true);
|
||||||
try {
|
try {
|
||||||
@@ -1517,6 +1511,357 @@ export function render({ navigate }) {
|
|||||||
await refreshUsage();
|
await refreshUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderSolanaPublishWallet() {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
const backBtn = createModeBackButton(renderWalletChoice);
|
||||||
|
|
||||||
|
const summaryCard = document.createElement('div');
|
||||||
|
summaryCard.className = 'card stack';
|
||||||
|
summaryCard.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Закрепление в Solana</h2>
|
||||||
|
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const publicationStatus = document.createElement('div');
|
||||||
|
publicationStatus.className = 'card stack';
|
||||||
|
publicationStatus.innerHTML = `
|
||||||
|
<h3 style="margin:0;">Статус публикации</h3>
|
||||||
|
<p class="meta-muted" id="publish-status-main">—</p>
|
||||||
|
<p class="meta-muted" id="publish-status-local">—</p>
|
||||||
|
`;
|
||||||
|
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
|
||||||
|
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
|
||||||
|
|
||||||
|
const pdaCard = document.createElement('div');
|
||||||
|
pdaCard.className = 'card stack';
|
||||||
|
pdaCard.innerHTML = `
|
||||||
|
<h3 style="margin:0;">Что закреплено в PDA</h3>
|
||||||
|
<p class="meta-muted" id="pda-name">Блокчейн: —</p>
|
||||||
|
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: —</p>
|
||||||
|
<p class="meta-muted" id="pda-last">Крайний блок: —</p>
|
||||||
|
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||||
|
<p class="meta-muted" id="pda-used">Размер: —</p>
|
||||||
|
<p class="meta-muted" id="pda-limit">Лимит: —</p>
|
||||||
|
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: —</p>
|
||||||
|
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serverCard = document.createElement('div');
|
||||||
|
serverCard.className = 'card stack';
|
||||||
|
serverCard.innerHTML = `
|
||||||
|
<h3 style="margin:0;">Что реально на сервере</h3>
|
||||||
|
<p class="meta-muted" id="server-name">Блокчейн: —</p>
|
||||||
|
<p class="meta-muted" id="server-last">Крайний блок: —</p>
|
||||||
|
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||||
|
<p class="meta-muted" id="server-size">Размер: —</p>
|
||||||
|
<p class="meta-muted" id="server-updated">Обновлено: —</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const localCard = document.createElement('div');
|
||||||
|
localCard.className = 'card stack';
|
||||||
|
localCard.innerHTML = `
|
||||||
|
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
|
||||||
|
<p class="meta-muted" id="local-state">Слепок: —</p>
|
||||||
|
<p class="meta-muted" id="local-transport">Способ: —</p>
|
||||||
|
<p class="meta-muted" id="local-last">Крайний блок: —</p>
|
||||||
|
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||||
|
<p class="meta-muted" id="local-size">Размер: —</p>
|
||||||
|
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: —</p>
|
||||||
|
<p class="meta-muted" id="local-created">Создан: —</p>
|
||||||
|
<p class="meta-muted" id="local-solana">Solana: —</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
|
||||||
|
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
|
||||||
|
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
|
||||||
|
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
|
||||||
|
`;
|
||||||
|
const refreshBtn = actions.querySelector('#refresh-solana-publish');
|
||||||
|
const createBtn = actions.querySelector('#create-solana-snapshot');
|
||||||
|
const publishBtn = actions.querySelector('#publish-solana-snapshot');
|
||||||
|
const clearBtn = actions.querySelector('#clear-solana-snapshot');
|
||||||
|
|
||||||
|
const pdaNameEl = pdaCard.querySelector('#pda-name');
|
||||||
|
const pdaAddressEl = pdaCard.querySelector('#pda-address');
|
||||||
|
const pdaLastEl = pdaCard.querySelector('#pda-last');
|
||||||
|
const pdaHashEl = pdaCard.querySelector('#pda-hash');
|
||||||
|
const pdaUsedEl = pdaCard.querySelector('#pda-used');
|
||||||
|
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
|
||||||
|
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
|
||||||
|
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
|
||||||
|
|
||||||
|
const serverNameEl = serverCard.querySelector('#server-name');
|
||||||
|
const serverLastEl = serverCard.querySelector('#server-last');
|
||||||
|
const serverHashEl = serverCard.querySelector('#server-hash');
|
||||||
|
const serverSizeEl = serverCard.querySelector('#server-size');
|
||||||
|
const serverUpdatedEl = serverCard.querySelector('#server-updated');
|
||||||
|
|
||||||
|
const localStateEl = localCard.querySelector('#local-state');
|
||||||
|
const localTransportEl = localCard.querySelector('#local-transport');
|
||||||
|
const localLastEl = localCard.querySelector('#local-last');
|
||||||
|
const localHashEl = localCard.querySelector('#local-hash');
|
||||||
|
const localSizeEl = localCard.querySelector('#local-size');
|
||||||
|
const localArweaveEl = localCard.querySelector('#local-arweave');
|
||||||
|
const localCreatedEl = localCard.querySelector('#local-created');
|
||||||
|
const localSolanaEl = localCard.querySelector('#local-solana');
|
||||||
|
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
refreshBtn.disabled = disabled;
|
||||||
|
createBtn.disabled = disabled;
|
||||||
|
publishBtn.disabled = disabled;
|
||||||
|
clearBtn.disabled = disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshState() {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const [usage, serverState] = await Promise.all([
|
||||||
|
getShineBlockchainUsage({
|
||||||
|
login,
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
}),
|
||||||
|
fetchServerBlockchainState(),
|
||||||
|
]);
|
||||||
|
const localSnapshot = readStoredBlockchainSnapshot(login);
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
|
||||||
|
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
|
||||||
|
if (!usage.arweaveTxId) {
|
||||||
|
publicationMainEl.textContent = 'не опубликовано';
|
||||||
|
} else if (pdaDelta > 0) {
|
||||||
|
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
|
||||||
|
} else {
|
||||||
|
publicationMainEl.textContent = 'опубликовано и полностью актуально';
|
||||||
|
}
|
||||||
|
|
||||||
|
let localStatusText = 'Локального слепка нет.';
|
||||||
|
const localMatchesServer = localSnapshot
|
||||||
|
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
|
||||||
|
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
|
||||||
|
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
|
||||||
|
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
|
||||||
|
if (localSnapshot?.txId && localMatchesServer) {
|
||||||
|
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
|
||||||
|
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
|
||||||
|
} else {
|
||||||
|
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
|
||||||
|
}
|
||||||
|
} else if (localSnapshot?.txId) {
|
||||||
|
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
|
||||||
|
}
|
||||||
|
publicationLocalEl.textContent = localStatusText;
|
||||||
|
|
||||||
|
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
|
||||||
|
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
|
||||||
|
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||||||
|
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||||||
|
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
|
||||||
|
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
|
||||||
|
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
|
||||||
|
if (usage.arweaveTxId) {
|
||||||
|
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
|
||||||
|
pdaArweaveLinkEl.style.pointerEvents = 'auto';
|
||||||
|
pdaArweaveLinkEl.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
pdaArweaveLinkEl.href = '#';
|
||||||
|
pdaArweaveLinkEl.style.pointerEvents = 'none';
|
||||||
|
pdaArweaveLinkEl.style.opacity = '.55';
|
||||||
|
}
|
||||||
|
|
||||||
|
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
|
||||||
|
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||||
|
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||||
|
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
|
||||||
|
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
|
||||||
|
|
||||||
|
if (localSnapshot?.txId) {
|
||||||
|
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
|
||||||
|
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
|
||||||
|
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
|
||||||
|
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
|
||||||
|
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
|
||||||
|
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
|
||||||
|
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
|
||||||
|
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
|
||||||
|
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
|
||||||
|
: 'Solana: слепок ещё не закреплён';
|
||||||
|
} else {
|
||||||
|
localStateEl.textContent = 'Слепок: отсутствует';
|
||||||
|
localTransportEl.textContent = 'Способ: —';
|
||||||
|
localLastEl.textContent = 'Крайний блок: —';
|
||||||
|
localHashEl.textContent = 'Hash: —';
|
||||||
|
localSizeEl.textContent = 'Размер: —';
|
||||||
|
localArweaveEl.textContent = 'Arweave tx: —';
|
||||||
|
localCreatedEl.textContent = 'Создан: —';
|
||||||
|
localSolanaEl.textContent = 'Solana: —';
|
||||||
|
}
|
||||||
|
|
||||||
|
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
|
||||||
|
clearBtn.disabled = !localSnapshot;
|
||||||
|
setStatus('Состояние публикации обновлено.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
createBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSnapshot() {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
setStatus('Собираю полный слепок блокчейна с сервера...');
|
||||||
|
const [usage, serverState] = await Promise.all([
|
||||||
|
getShineBlockchainUsage({
|
||||||
|
login,
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
}),
|
||||||
|
fetchServerBlockchainState(),
|
||||||
|
]);
|
||||||
|
const snapshotFile = await buildBlockchainSnapshotFile({
|
||||||
|
authService,
|
||||||
|
login,
|
||||||
|
blockchainName: usage.blockchainName || serverState.blockchainName,
|
||||||
|
lastBlockNumber: serverState.lastNumber,
|
||||||
|
});
|
||||||
|
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
|
||||||
|
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
|
||||||
|
const uploaded = await openArweaveAttachmentManager({
|
||||||
|
login,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
gateway: state.entrySettings.arweaveServer,
|
||||||
|
historyOnly: false,
|
||||||
|
persistToHistory: false,
|
||||||
|
allowHistorySelection: false,
|
||||||
|
allowExistingTxInput: false,
|
||||||
|
uploadTransport: 'turbo',
|
||||||
|
dialogTitle: 'Сделать слепок блокчейна',
|
||||||
|
uploadButtonLabel: 'Сделать слепок',
|
||||||
|
initialFile: snapshotFile.file,
|
||||||
|
initialSha256: snapshotSha256,
|
||||||
|
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
|
||||||
|
fixedFile: true,
|
||||||
|
autoOpenFileDialog: false,
|
||||||
|
shineType: 'blockchain-snapshot',
|
||||||
|
extraUploadTags: [
|
||||||
|
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
|
||||||
|
{ name: 'SHiNE-Login', value: login },
|
||||||
|
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
|
||||||
|
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
|
||||||
|
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
if (!uploaded?.ar) {
|
||||||
|
setStatus('Загрузка слепка отменена.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStoredBlockchainSnapshot(login, {
|
||||||
|
blockchainName: snapshotFile.blockchainName,
|
||||||
|
txId: String(uploaded.ar || '').trim(),
|
||||||
|
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
|
||||||
|
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
|
||||||
|
usedBytes: Number(serverState.sizeBytes || 0),
|
||||||
|
lastBlockNumber: Number(serverState.lastNumber),
|
||||||
|
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
|
||||||
|
uploadedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
|
||||||
|
await refreshState();
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
if (modeToken === activeModeToken) {
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSnapshotToSolana() {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const localSnapshot = readStoredBlockchainSnapshot(login);
|
||||||
|
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
|
||||||
|
const [usage, serverState, signing] = await Promise.all([
|
||||||
|
getShineBlockchainUsage({
|
||||||
|
login,
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
}),
|
||||||
|
fetchServerBlockchainState(),
|
||||||
|
resolveWalletSigningMaterial(),
|
||||||
|
]);
|
||||||
|
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
|
||||||
|
throw new Error('Локальный слепок относится к другому блокчейну.');
|
||||||
|
}
|
||||||
|
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|
||||||
|
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|
||||||
|
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
|
||||||
|
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateShineUserPdaOnSolana({
|
||||||
|
login,
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||||
|
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||||
|
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
||||||
|
additionalLimitBytes: 0n,
|
||||||
|
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||||||
|
nextLastBlockNumber: serverState.lastNumber,
|
||||||
|
nextLastBlockHashHex: serverState.lastHash,
|
||||||
|
nextArweaveTxId: localSnapshot.txId,
|
||||||
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
saveStoredBlockchainSnapshot(login, {
|
||||||
|
...localSnapshot,
|
||||||
|
solanaUpdatedAtMs: Date.now(),
|
||||||
|
solanaSignature: String(result.signature || '').trim(),
|
||||||
|
});
|
||||||
|
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
|
||||||
|
await refreshState();
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
if (modeToken === activeModeToken) {
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
void refreshState();
|
||||||
|
});
|
||||||
|
createBtn.addEventListener('click', () => {
|
||||||
|
void createSnapshot();
|
||||||
|
});
|
||||||
|
publishBtn.addEventListener('click', () => {
|
||||||
|
void publishSnapshotToSolana();
|
||||||
|
});
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
clearStoredBlockchainSnapshot(login);
|
||||||
|
setStatus('Локальный слепок удалён из браузера.');
|
||||||
|
void refreshState();
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
|
||||||
|
setStatus('Загрузка состояния публикации...');
|
||||||
|
await refreshState();
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSolanaWallet() {
|
async function renderSolanaWallet() {
|
||||||
const modeToken = ++activeModeToken;
|
const modeToken = ++activeModeToken;
|
||||||
clearArweaveSecretsInMemory();
|
clearArweaveSecretsInMemory();
|
||||||
|
|||||||
+30
-4
@@ -1,6 +1,7 @@
|
|||||||
import { parseShineRouteParts } from './services/shine-routes.js';
|
import { parseShineRouteParts } from './services/shine-routes.js';
|
||||||
|
|
||||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
let previousTrackedPath = '';
|
||||||
|
let currentTrackedPath = String(window.location.pathname || '').trim() || '/';
|
||||||
const PRETTY_PATHS = new Map([
|
const PRETTY_PATHS = new Map([
|
||||||
['start-view', 'start'],
|
['start-view', 'start'],
|
||||||
['entry-settings-view', 'entry-settings'],
|
['entry-settings-view', 'entry-settings'],
|
||||||
@@ -68,8 +69,8 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'key-storage-view',
|
'key-storage-view',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getRoute() {
|
export function parseRouteFromPath(pathname = '') {
|
||||||
const currentPath = String(window.location.pathname || '').trim();
|
const currentPath = String(pathname || '').trim();
|
||||||
const raw = currentPath
|
const raw = currentPath
|
||||||
.replace(/^\/+/, '')
|
.replace(/^\/+/, '')
|
||||||
.replace(/^index\.html$/i, '')
|
.replace(/^index\.html$/i, '')
|
||||||
@@ -344,9 +345,17 @@ export function getRoute() {
|
|||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRoute() {
|
||||||
|
return parseRouteFromPath(window.location.pathname || '');
|
||||||
|
}
|
||||||
|
|
||||||
export function navigate(path) {
|
export function navigate(path) {
|
||||||
const cleanPath = toPrettyPath(path);
|
const cleanPath = toPrettyPath(path);
|
||||||
const nextPath = cleanPath ? `/${cleanPath}` : '/';
|
const nextPath = cleanPath ? `/${cleanPath}` : '/';
|
||||||
|
if (window.location.pathname !== nextPath) {
|
||||||
|
previousTrackedPath = currentTrackedPath;
|
||||||
|
currentTrackedPath = nextPath;
|
||||||
|
}
|
||||||
if (window.location.pathname !== nextPath) {
|
if (window.location.pathname !== nextPath) {
|
||||||
window.history.pushState({}, '', nextPath);
|
window.history.pushState({}, '', nextPath);
|
||||||
}
|
}
|
||||||
@@ -369,7 +378,13 @@ export function navigateBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveToolbarActive(pageId) {
|
export function resolveToolbarActive(pageId) {
|
||||||
if (ROOT_PAGES.includes(pageId)) return pageId;
|
if (
|
||||||
|
pageId === 'messages-list'
|
||||||
|
|| pageId === 'channels-list'
|
||||||
|
|| pageId === 'network-view'
|
||||||
|
|| pageId === 'notifications-view'
|
||||||
|
|| pageId === 'profile-view'
|
||||||
|
) return pageId;
|
||||||
if (
|
if (
|
||||||
pageId === 'profile-edit-view' ||
|
pageId === 'profile-edit-view' ||
|
||||||
pageId === 'wallet-view' ||
|
pageId === 'wallet-view' ||
|
||||||
@@ -397,3 +412,14 @@ export function resolveToolbarActive(pageId) {
|
|||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'user') return 'messages-list';
|
||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncTrackedRouteHistory(pathname = '') {
|
||||||
|
const nextPath = String(pathname || '').trim() || '/';
|
||||||
|
if (nextPath === currentTrackedPath) return;
|
||||||
|
previousTrackedPath = currentTrackedPath;
|
||||||
|
currentTrackedPath = nextPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousTrackedPath() {
|
||||||
|
return previousTrackedPath;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
||||||
|
|
||||||
const ATTACH_PREFIX = '<SHiNE:attach;';
|
const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u;
|
||||||
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
|
|
||||||
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
||||||
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
||||||
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||||
@@ -33,8 +32,8 @@ function normalizeName(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePreview(input = {}) {
|
function normalizePreview(input = {}) {
|
||||||
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
|
const previewTxId = String(input.preAr || input.previewAr || input.ar || input.txId || '').trim();
|
||||||
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
const previewSha256Hex = String(input.preSha256 || input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
if (!previewTxId || !previewSha256Hex) return null;
|
if (!previewTxId || !previewSha256Hex) return null;
|
||||||
if (!validateArweaveTxId(previewTxId)) return null;
|
if (!validateArweaveTxId(previewTxId)) return null;
|
||||||
if (!validateSha256Hex(previewSha256Hex)) return null;
|
if (!validateSha256Hex(previewSha256Hex)) return null;
|
||||||
@@ -47,9 +46,11 @@ function normalizePreview(input = {}) {
|
|||||||
export function normalizeAttachment(input = {}) {
|
export function normalizeAttachment(input = {}) {
|
||||||
const txId = String(input.ar || input.txId || '').trim();
|
const txId = String(input.ar || input.txId || '').trim();
|
||||||
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
const size = Number(input.size || input.sizeBytes || 0);
|
const size = Number(input.sz || input.size || input.sizeBytes || 0);
|
||||||
const name = normalizeName(input.name || input.fileName || 'file');
|
const name = normalizeName(input.nm || input.name || input.fileName || 'file');
|
||||||
const preview = normalizePreview(input.preview || {
|
const preview = normalizePreview(input.preview || {
|
||||||
|
preAr: input.preAr,
|
||||||
|
preSha256: input.preSha256,
|
||||||
previewAr: input.previewAr,
|
previewAr: input.previewAr,
|
||||||
previewSha256: input.previewSha256,
|
previewSha256: input.previewSha256,
|
||||||
});
|
});
|
||||||
@@ -75,9 +76,9 @@ export function buildAttachmentBlock(attachment) {
|
|||||||
const item = normalizeAttachment(attachment);
|
const item = normalizeAttachment(attachment);
|
||||||
const encodedName = encodeURIComponent(item.name);
|
const encodedName = encodeURIComponent(item.name);
|
||||||
const previewFields = item.preview
|
const previewFields = item.preview
|
||||||
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
|
? `;preAr=${item.preview.ar};preSha256=${item.preview.sha256}`
|
||||||
: '';
|
: '';
|
||||||
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
return `<S:att;v=1;nm=${encodedName};sz=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composeMessageWithAttachments(text, attachments = []) {
|
export function composeMessageWithAttachments(text, attachments = []) {
|
||||||
@@ -105,16 +106,22 @@ export function parseMessageAttachments(rawText) {
|
|||||||
let rest = String(rawText || '');
|
let rest = String(rawText || '');
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
|
|
||||||
while (rest.startsWith(ATTACH_PREFIX)) {
|
while (true) {
|
||||||
const match = rest.match(ATTACH_BLOCK_RE);
|
const match = rest.match(ATTACH_BLOCK_RE);
|
||||||
if (!match) break;
|
if (!match) break;
|
||||||
const fields = parseFields(match[1]);
|
const fields = parseFields(match[2]);
|
||||||
try {
|
try {
|
||||||
|
const version = Number(fields.v || 0);
|
||||||
|
if (version !== 1 && version !== 2) {
|
||||||
|
throw new Error('unsupported attachment version');
|
||||||
|
}
|
||||||
attachments.push(normalizeAttachment({
|
attachments.push(normalizeAttachment({
|
||||||
name: decodeURIComponent(String(fields.name || 'file')),
|
nm: decodeURIComponent(String(fields.nm || fields.name || 'file')),
|
||||||
size: fields.size,
|
sz: fields.sz || fields.size,
|
||||||
sha256: fields.sha256,
|
sha256: fields.sha256,
|
||||||
ar: fields.ar,
|
ar: fields.ar,
|
||||||
|
preAr: fields.preAr,
|
||||||
|
preSha256: fields.preSha256,
|
||||||
previewAr: fields.previewAr,
|
previewAr: fields.previewAr,
|
||||||
previewSha256: fields.previewSha256,
|
previewSha256: fields.previewSha256,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null }
|
|||||||
const cleanTitle = validateChannelMetaTitle(title);
|
const cleanTitle = validateChannelMetaTitle(title);
|
||||||
const cleanDescription = normalizeChannelMetaDescription(description);
|
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
if (cleanTitle) rows.push(`<S:title;v=1;${cleanTitle}>`);
|
||||||
if (avatar?.ar) {
|
if (avatar?.ar) {
|
||||||
const size = Number(avatar.size || 0);
|
const size = Number(avatar.size || 0);
|
||||||
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
||||||
@@ -803,7 +803,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null }
|
|||||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||||
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
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 аватара.');
|
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}>`);
|
rows.push(`<S:ava;v=1;sz=${size};sha256=${sha256};ar=${ar}>`);
|
||||||
}
|
}
|
||||||
if (cleanDescription) rows.push(cleanDescription);
|
if (cleanDescription) rows.push(cleanDescription);
|
||||||
return rows.join('\n');
|
return rows.join('\n');
|
||||||
@@ -1022,6 +1022,18 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveCanonicalDisplayLogin(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return '';
|
||||||
|
try {
|
||||||
|
const user = await this.getUser(cleanLogin);
|
||||||
|
const canonicalLogin = String(user?.login || '').trim();
|
||||||
|
return canonicalLogin || cleanLogin;
|
||||||
|
} catch {
|
||||||
|
return cleanLogin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async ensureLoginFree(login) {
|
async ensureLoginFree(login) {
|
||||||
const payload = await this.getUser(login);
|
const payload = await this.getUser(login);
|
||||||
return payload.exists !== true;
|
return payload.exists !== true;
|
||||||
@@ -1127,8 +1139,10 @@ export class AuthService {
|
|||||||
const sessionId = createResp?.payload?.sessionId;
|
const sessionId = createResp?.payload?.sessionId;
|
||||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||||
|
|
||||||
|
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: cleanLogin,
|
login: canonicalLogin,
|
||||||
sessionId,
|
sessionId,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
sessionMaterial: {
|
sessionMaterial: {
|
||||||
@@ -1213,8 +1227,10 @@ export class AuthService {
|
|||||||
const sessionId = createResp?.payload?.sessionId;
|
const sessionId = createResp?.payload?.sessionId;
|
||||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||||
|
|
||||||
|
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: cleanLogin,
|
login: canonicalLogin,
|
||||||
sessionId,
|
sessionId,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
sessionKey: cleanSessionKey,
|
sessionKey: cleanSessionKey,
|
||||||
@@ -1287,8 +1303,10 @@ export class AuthService {
|
|||||||
const storagePwd = loginResp?.payload?.storagePwd;
|
const storagePwd = loginResp?.payload?.storagePwd;
|
||||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||||
|
|
||||||
|
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: cleanLogin,
|
login: canonicalLogin,
|
||||||
sessionId: targetSessionId,
|
sessionId: targetSessionId,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
};
|
};
|
||||||
@@ -2476,6 +2494,10 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeDmLogin(login) {
|
||||||
|
return String(login || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
async buildSignedDmBlock({
|
async buildSignedDmBlock({
|
||||||
signerLogin,
|
signerLogin,
|
||||||
fromLogin,
|
fromLogin,
|
||||||
@@ -2488,9 +2510,9 @@ export class AuthService {
|
|||||||
reencryptedAtMs = 0,
|
reencryptedAtMs = 0,
|
||||||
bodyBytes = new Uint8Array(0),
|
bodyBytes = new Uint8Array(0),
|
||||||
}) {
|
}) {
|
||||||
const cleanSignerLogin = String(signerLogin || '').trim();
|
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
|
||||||
const cleanFromLogin = String(fromLogin || '').trim();
|
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
|
||||||
const cleanToLogin = String(toLogin || '').trim();
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||||
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
|
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
|
||||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||||
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||||
@@ -2590,8 +2612,8 @@ export class AuthService {
|
|||||||
revisionTimeMs = 0,
|
revisionTimeMs = 0,
|
||||||
reencryptedAtMs = 0,
|
reencryptedAtMs = 0,
|
||||||
}) {
|
}) {
|
||||||
const cleanFromLogin = String(login || '').trim();
|
const cleanFromLogin = this.normalizeDmLogin(login);
|
||||||
const cleanToLogin = String(toLogin || '').trim();
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||||
const cleanText = String(text || '');
|
const cleanText = String(text || '');
|
||||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||||
const normalizedTimeMs = Number(timeMs);
|
const normalizedTimeMs = Number(timeMs);
|
||||||
@@ -2674,8 +2696,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
const cleanPeerLogin = String(toLogin || '').trim();
|
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
||||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||||
const normalizedTimeMs = Number(timeMs);
|
const normalizedTimeMs = Number(timeMs);
|
||||||
const normalizedNonce = Number(nonce);
|
const normalizedNonce = Number(nonce);
|
||||||
@@ -2706,14 +2728,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
||||||
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||||
|
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
|
||||||
|
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
|
||||||
const timeMs = Date.now();
|
const timeMs = Date.now();
|
||||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
|
const payload = buildReadReceiptPayloadBytes({
|
||||||
|
refToLogin: cleanRefToLogin,
|
||||||
|
refFromLogin: cleanRefFromLogin,
|
||||||
|
refTimeMs,
|
||||||
|
refNonce,
|
||||||
|
});
|
||||||
|
|
||||||
const type3 = await this.buildSignedDmBlock({
|
const type3 = await this.buildSignedDmBlock({
|
||||||
signerLogin: login,
|
signerLogin: cleanLogin,
|
||||||
fromLogin: login,
|
fromLogin: cleanLogin,
|
||||||
toLogin,
|
toLogin: cleanToLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -2721,9 +2752,9 @@ export class AuthService {
|
|||||||
bodyBytes: payload,
|
bodyBytes: payload,
|
||||||
});
|
});
|
||||||
const type4 = await this.buildSignedDmBlock({
|
const type4 = await this.buildSignedDmBlock({
|
||||||
signerLogin: login,
|
signerLogin: cleanLogin,
|
||||||
fromLogin: login,
|
fromLogin: cleanLogin,
|
||||||
toLogin,
|
toLogin: cleanToLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -2737,8 +2768,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
|
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
const cleanPeerLogin = String(toLogin || '').trim();
|
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
||||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||||
const normalizedTimeMs = Number(timeMs);
|
const normalizedTimeMs = Number(timeMs);
|
||||||
const normalizedNonce = Number(nonce);
|
const normalizedNonce = Number(nonce);
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ function defaultParsed(rawText = '') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTechPrefix(text = '', offset = 0) {
|
||||||
|
return String(text || '').startsWith('<SHiNE:', offset) || String(text || '').startsWith('<S:', offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTechPrefixLength(text = '', offset = 0) {
|
||||||
|
if (String(text || '').startsWith('<SHiNE:', offset)) return 7;
|
||||||
|
if (String(text || '').startsWith('<S:', offset)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function parseReplyId(value = '') {
|
function parseReplyId(value = '') {
|
||||||
const parts = String(value || '').split('|');
|
const parts = String(value || '').split('|');
|
||||||
if (parts.length !== 4) return null;
|
if (parts.length !== 4) return null;
|
||||||
@@ -57,41 +67,45 @@ function buildCallDisplayText(callSummary) {
|
|||||||
|
|
||||||
export function sanitizeUserDmTextForSend(rawText = '') {
|
export function sanitizeUserDmTextForSend(rawText = '') {
|
||||||
const text = String(rawText || '');
|
const text = String(rawText || '');
|
||||||
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : text;
|
if (text.startsWith('<SHiNE:')) return `< SHiNE:${text.slice(7)}`;
|
||||||
|
if (text.startsWith('<S:')) return `< S:${text.slice(3)}`;
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
||||||
const cleanStatus = String(status || '').trim().toLowerCase();
|
const cleanStatus = String(status || '').trim().toLowerCase();
|
||||||
if (cleanStatus === 'completed') {
|
if (cleanStatus === 'completed') {
|
||||||
return `<SHiNE:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
return `<S:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
||||||
}
|
}
|
||||||
const cleanReason = String(reason || '').trim().toLowerCase();
|
const cleanReason = String(reason || '').trim().toLowerCase();
|
||||||
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
return `<S:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
||||||
const cleanBaseKey = String(baseKey || '').trim();
|
const cleanBaseKey = String(baseKey || '').trim();
|
||||||
if (!cleanBaseKey) return '';
|
if (!cleanBaseKey) return '';
|
||||||
return `<SHiNE:reply;v=1;id=${cleanBaseKey}>`;
|
return `<S:reply;v=1;id=${cleanBaseKey}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDmTechBlocks(rawText = '') {
|
export function parseDmTechBlocks(rawText = '') {
|
||||||
const text = String(rawText || '');
|
const text = String(rawText || '');
|
||||||
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
|
if (!hasTechPrefix(text)) return defaultParsed(text);
|
||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let replyRef = null;
|
let replyRef = null;
|
||||||
let callSummary = null;
|
let callSummary = null;
|
||||||
|
|
||||||
while (text.startsWith('<SHiNE:', cursor)) {
|
while (hasTechPrefix(text, cursor)) {
|
||||||
const end = text.indexOf('>', cursor);
|
const end = text.indexOf('>', cursor);
|
||||||
if (end < 0) {
|
if (end < 0) {
|
||||||
if (!blocks.length) return defaultParsed(text);
|
if (!blocks.length) return defaultParsed(text);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inner = text.slice(cursor + 7, end);
|
const prefixLength = getTechPrefixLength(text, cursor);
|
||||||
|
if (!prefixLength) break;
|
||||||
|
const inner = text.slice(cursor + prefixLength, end);
|
||||||
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
|
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
if (!blocks.length) return defaultParsed(text);
|
if (!blocks.length) return defaultParsed(text);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const DB_VERSION = 1;
|
|||||||
const STORE_SECRETS = 'encrypted-secrets';
|
const STORE_SECRETS = 'encrypted-secrets';
|
||||||
const STORE_SESSIONS = 'session-keys';
|
const STORE_SESSIONS = 'session-keys';
|
||||||
|
|
||||||
|
function normalizeLoginStorageKey(login) {
|
||||||
|
return String(login || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function openDb() {
|
function openDb() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
@@ -50,15 +54,20 @@ async function get(storeName, key) {
|
|||||||
|
|
||||||
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
|
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
|
||||||
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
|
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
|
||||||
|
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||||
await put(STORE_SECRETS, {
|
await put(STORE_SECRETS, {
|
||||||
login,
|
login: normalizedLogin,
|
||||||
encrypted,
|
encrypted,
|
||||||
updatedAtMs: Date.now(),
|
updatedAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEncryptedUserSecrets(login, storagePwd) {
|
export async function loadEncryptedUserSecrets(login, storagePwd) {
|
||||||
const row = await get(STORE_SECRETS, login);
|
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||||
|
let row = await get(STORE_SECRETS, normalizedLogin);
|
||||||
|
if (!row?.encrypted && normalizedLogin !== String(login || '').trim()) {
|
||||||
|
row = await get(STORE_SECRETS, String(login || '').trim());
|
||||||
|
}
|
||||||
if (!row?.encrypted) {
|
if (!row?.encrypted) {
|
||||||
throw new Error('На устройстве нет сохранённых ключей для этого логина');
|
throw new Error('На устройстве нет сохранённых ключей для этого логина');
|
||||||
}
|
}
|
||||||
@@ -80,15 +89,19 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionMaterial(login, material) {
|
export async function saveSessionMaterial(login, material) {
|
||||||
|
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||||
await put(STORE_SESSIONS, {
|
await put(STORE_SESSIONS, {
|
||||||
login,
|
login: normalizedLogin,
|
||||||
...material,
|
...material,
|
||||||
updatedAtMs: Date.now(),
|
updatedAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSessionMaterial(login) {
|
export async function loadSessionMaterial(login) {
|
||||||
return get(STORE_SESSIONS, login);
|
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||||
|
const row = await get(STORE_SESSIONS, normalizedLogin);
|
||||||
|
if (row || normalizedLogin === String(login || '').trim()) return row;
|
||||||
|
return get(STORE_SESSIONS, String(login || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearClientAuthData() {
|
export async function clearClientAuthData() {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
const SNAPSHOT_STORAGE_KEY = 'shine-ui-blockchain-snapshot-v1';
|
||||||
|
|
||||||
|
function normalizeLogin(login) {
|
||||||
|
return String(login || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageBucketKey(login) {
|
||||||
|
return normalizeLogin(login) || 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToBytes(base64) {
|
||||||
|
const raw = String(base64 || '').trim();
|
||||||
|
if (!raw) return new Uint8Array();
|
||||||
|
const bin = atob(raw);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStorageMap() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStorageMap(value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(value || {}));
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredBlockchainSnapshot(login) {
|
||||||
|
const key = storageBucketKey(login);
|
||||||
|
const map = readStorageMap();
|
||||||
|
const value = map?.[key];
|
||||||
|
return value && typeof value === 'object' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStoredBlockchainSnapshot(login, snapshot) {
|
||||||
|
const key = storageBucketKey(login);
|
||||||
|
const map = readStorageMap();
|
||||||
|
map[key] = {
|
||||||
|
...snapshot,
|
||||||
|
savedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
writeStorageMap(map);
|
||||||
|
return map[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredBlockchainSnapshot(login) {
|
||||||
|
const key = storageBucketKey(login);
|
||||||
|
const map = readStorageMap();
|
||||||
|
delete map[key];
|
||||||
|
writeStorageMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBlockchainSnapshotFile({
|
||||||
|
authService,
|
||||||
|
login,
|
||||||
|
blockchainName,
|
||||||
|
lastBlockNumber,
|
||||||
|
} = {}) {
|
||||||
|
const cleanLogin = normalizeLogin(login);
|
||||||
|
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||||
|
const maxBlockNumber = Number(lastBlockNumber);
|
||||||
|
if (!authService?.ws?.request) throw new Error('Сервис сервера недоступен.');
|
||||||
|
if (!cleanLogin) throw new Error('Не указан логин.');
|
||||||
|
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна.');
|
||||||
|
if (!Number.isFinite(maxBlockNumber) || maxBlockNumber < 0) {
|
||||||
|
throw new Error('На сервере нет блоков для слепка.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (let blockNumber = 0; blockNumber <= maxBlockNumber; blockNumber += 1) {
|
||||||
|
const response = await authService.ws.request('GetBlockchainBlock', {
|
||||||
|
blockchainName: cleanBlockchainName,
|
||||||
|
blockNumber,
|
||||||
|
});
|
||||||
|
if (response?.status !== 200) {
|
||||||
|
const message = String(response?.payload?.message || response?.message || 'Не удалось получить блок.');
|
||||||
|
throw new Error(`Не удалось скачать блок ${blockNumber}: ${message}`);
|
||||||
|
}
|
||||||
|
const blockBytes = decodeBase64ToBytes(response?.payload?.blockBytesB64 || '');
|
||||||
|
parts.push(blockBytes);
|
||||||
|
totalBytes += blockBytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new File(parts, `${cleanBlockchainName}.shine-blockchain`, {
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
blockCount: maxBlockNumber + 1,
|
||||||
|
totalBytes,
|
||||||
|
lastBlockNumber: maxBlockNumber,
|
||||||
|
login: cleanLogin,
|
||||||
|
blockchainName: cleanBlockchainName,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -721,6 +721,8 @@ export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
|
|||||||
paidLimitBytes: bch.paidLimitBytes,
|
paidLimitBytes: bch.paidLimitBytes,
|
||||||
usedBytes: bch.usedBytes,
|
usedBytes: bch.usedBytes,
|
||||||
leftBytes,
|
leftBytes,
|
||||||
|
blockchainName: String(bch.blockchainName || ''),
|
||||||
|
arweaveTxId: String(bch.arweaveTxId || ''),
|
||||||
lastBlockNumber: bch.lastBlockNumber,
|
lastBlockNumber: bch.lastBlockNumber,
|
||||||
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||||
};
|
};
|
||||||
@@ -762,9 +764,10 @@ async function attachSolanaLogs(error, connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
||||||
|
const rawLogin = String(login || '').trim();
|
||||||
const cleanLogin = normalizeLogin(login);
|
const cleanLogin = normalizeLogin(login);
|
||||||
const endpoint = String(solanaEndpoint || '').trim();
|
const endpoint = String(solanaEndpoint || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Не указан логин');
|
if (!rawLogin || !cleanLogin) throw new Error('Не указан логин');
|
||||||
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
||||||
|
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
@@ -791,6 +794,7 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
|||||||
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
rawLogin,
|
||||||
cleanLogin,
|
cleanLogin,
|
||||||
endpoint,
|
endpoint,
|
||||||
solana,
|
solana,
|
||||||
@@ -832,18 +836,19 @@ async function createShineUserPdaOnSolana({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanLogin = ctx.cleanLogin;
|
const cleanLogin = ctx.cleanLogin;
|
||||||
|
const displayLogin = ctx.rawLogin;
|
||||||
const cleanPromoCode = String(promoCode || '').trim();
|
const cleanPromoCode = String(promoCode || '').trim();
|
||||||
const blockchainName = `${cleanLogin}-001`;
|
const blockchainName = `${cleanLogin}-001`;
|
||||||
const zeroHash32 = new Uint8Array(32);
|
const zeroHash32 = new Uint8Array(32);
|
||||||
const createdAtMs = BigInt(Date.now());
|
const createdAtMs = BigInt(Date.now());
|
||||||
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
|
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
|
||||||
|
|
||||||
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
|
const lastBlockStateBytes = buildLastBlockStateBytes(displayLogin, blockchainName, 0, zeroHash32, 0n);
|
||||||
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
||||||
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
|
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
|
||||||
|
|
||||||
const initialState = createPdaState({
|
const initialState = createPdaState({
|
||||||
login: cleanLogin,
|
login: displayLogin,
|
||||||
createdAtMs,
|
createdAtMs,
|
||||||
updatedAtMs: createdAtMs,
|
updatedAtMs: createdAtMs,
|
||||||
recordNumber: 0,
|
recordNumber: 0,
|
||||||
@@ -910,7 +915,7 @@ async function createShineUserPdaOnSolana({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const ixData = serializeCreateUserPdaArgs({
|
const ixData = serializeCreateUserPdaArgs({
|
||||||
login: cleanLogin,
|
login: displayLogin,
|
||||||
recoveryKey32: ctx.recoveryKey32,
|
recoveryKey32: ctx.recoveryKey32,
|
||||||
rootKey32: ctx.rootKey32,
|
rootKey32: ctx.rootKey32,
|
||||||
createdAtMs,
|
createdAtMs,
|
||||||
@@ -1028,6 +1033,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
nextUsedBytes,
|
nextUsedBytes,
|
||||||
nextLastBlockNumber,
|
nextLastBlockNumber,
|
||||||
nextLastBlockHashHex,
|
nextLastBlockHashHex,
|
||||||
|
nextArweaveTxId,
|
||||||
serverProfile,
|
serverProfile,
|
||||||
accessServers,
|
accessServers,
|
||||||
trustedCount,
|
trustedCount,
|
||||||
@@ -1043,6 +1049,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
||||||
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
||||||
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
|
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
|
||||||
|
const effectiveArweaveTxId = nextArweaveTxId == null ? currentBch.arweaveTxId : String(nextArweaveTxId || '').trim();
|
||||||
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
||||||
|
|
||||||
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
||||||
@@ -1060,7 +1067,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
||||||
|
|
||||||
const lastBlockStateBytes = buildLastBlockStateBytes(
|
const lastBlockStateBytes = buildLastBlockStateBytes(
|
||||||
cleanLogin,
|
current.login,
|
||||||
currentBch.blockchainName,
|
currentBch.blockchainName,
|
||||||
effectiveLastNum,
|
effectiveLastNum,
|
||||||
effectiveLastHash,
|
effectiveLastHash,
|
||||||
@@ -1085,7 +1092,10 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
const updatedAtMs = BigInt(Date.now());
|
const updatedAtMs = BigInt(Date.now());
|
||||||
const newPaid = currentBch.paidLimitBytes + addLimit;
|
const newPaid = currentBch.paidLimitBytes + addLimit;
|
||||||
const newRecordNumber = current.recordNumber + 1;
|
const newRecordNumber = current.recordNumber + 1;
|
||||||
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
|
// Для prev_hash нужно хэшировать точную unsigned-часть текущей PDA,
|
||||||
|
// а не пересобирать её из распарсенного состояния: иначе можно получить
|
||||||
|
// несовпадение байт и InvalidPrevHash в on-chain программе.
|
||||||
|
const prevHash = await sha256Bytes(current.unsignedBytes || serializeUnsignedRecordFromState(current));
|
||||||
|
|
||||||
const nextServerProfile = serverProfile
|
const nextServerProfile = serverProfile
|
||||||
? {
|
? {
|
||||||
@@ -1097,7 +1107,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
: current.serverProfile;
|
: current.serverProfile;
|
||||||
|
|
||||||
const nextState = createPdaState({
|
const nextState = createPdaState({
|
||||||
login: cleanLogin,
|
login: current.login,
|
||||||
createdAtMs: current.createdAtMs,
|
createdAtMs: current.createdAtMs,
|
||||||
updatedAtMs,
|
updatedAtMs,
|
||||||
recordNumber: newRecordNumber,
|
recordNumber: newRecordNumber,
|
||||||
@@ -1113,7 +1123,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
lastBlockNumber: effectiveLastNum,
|
lastBlockNumber: effectiveLastNum,
|
||||||
lastBlockHash: effectiveLastHash,
|
lastBlockHash: effectiveLastHash,
|
||||||
lastBlockSignature: lastBlockSig64,
|
lastBlockSignature: lastBlockSig64,
|
||||||
arweaveTxId: currentBch.arweaveTxId,
|
arweaveTxId: effectiveArweaveTxId,
|
||||||
}),
|
}),
|
||||||
isServer: Boolean(nextServerProfile),
|
isServer: Boolean(nextServerProfile),
|
||||||
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
|
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
|
||||||
@@ -1131,7 +1141,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
|
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
|
||||||
|
|
||||||
const ixData = serializeUpdateUserPdaArgs({
|
const ixData = serializeUpdateUserPdaArgs({
|
||||||
login: cleanLogin,
|
login: current.login,
|
||||||
recoveryKey32: current.recoveryKey,
|
recoveryKey32: current.recoveryKey,
|
||||||
rootKey32: current.rootKey,
|
rootKey32: current.rootKey,
|
||||||
createdAtMs: current.createdAtMs,
|
createdAtMs: current.createdAtMs,
|
||||||
@@ -1146,7 +1156,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
lastBlockNumber: effectiveLastNum,
|
lastBlockNumber: effectiveLastNum,
|
||||||
lastBlockHash32: effectiveLastHash,
|
lastBlockHash32: effectiveLastHash,
|
||||||
lastBlockSignature64: lastBlockSig64,
|
lastBlockSignature64: lastBlockSig64,
|
||||||
arweaveTxId: currentBch.arweaveTxId,
|
arweaveTxId: effectiveArweaveTxId,
|
||||||
isServer: nextState.isServer,
|
isServer: nextState.isServer,
|
||||||
addressFormatType: nextState.addressFormatType,
|
addressFormatType: nextState.addressFormatType,
|
||||||
addressFormatVersion: nextState.addressFormatVersion,
|
addressFormatVersion: nextState.addressFormatVersion,
|
||||||
@@ -1205,6 +1215,7 @@ export async function updateShineUserPdaOnSolana({
|
|||||||
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
|
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
|
||||||
lastBlockNumber: effectiveLastNum,
|
lastBlockNumber: effectiveLastNum,
|
||||||
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||||
|
arweaveTxId: effectiveArweaveTxId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
.topbar-slot .dm-head,
|
.topbar-slot .dm-head,
|
||||||
.topbar-slot .channels-top-bar {
|
.topbar-slot .channels-top-bar {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
min-height: calc(68px + env(safe-area-inset-top));
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(14, 21, 35, 0.92);
|
background: rgba(14, 21, 35, 0.92);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.18);
|
border: 1px solid rgba(212, 175, 55, 0.18);
|
||||||
@@ -30,10 +32,22 @@
|
|||||||
|
|
||||||
.topbar-slot .dm-head,
|
.topbar-slot .dm-head,
|
||||||
.topbar-slot .channels-top-bar {
|
.topbar-slot .channels-top-bar {
|
||||||
|
min-height: calc(68px + env(safe-area-inset-top));
|
||||||
padding-top: calc(10px + env(safe-area-inset-top));
|
padding-top: calc(10px + env(safe-area-inset-top));
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-slot .dm-head {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-slot .page-header,
|
||||||
|
.topbar-slot .dm-head,
|
||||||
|
.topbar-slot .channels-top-bar {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.app-topbar-shell .page-title {
|
.app-topbar-shell .page-title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -5065,7 +5079,8 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-top-back-btn,
|
.channels-top-back-btn,
|
||||||
.channels-top-add-btn,
|
.channels-top-add-btn,
|
||||||
.channels-top-search-btn {
|
.channels-top-search-btn,
|
||||||
|
.channels-top-more-btn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
@@ -5089,6 +5104,12 @@ textarea.input {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(214, 249, 255, 0.2), 0 0 18px rgba(93, 211, 255, 0.38);
|
box-shadow: inset 0 0 0 1px rgba(214, 249, 255, 0.2), 0 0 18px rgba(93, 211, 255, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-top-more-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-search-icon {
|
.channels-search-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -5752,6 +5773,25 @@ textarea.input {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar .page-title,
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar .header-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar .header-left {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-screen--channel .page-header .icon-btn,
|
.channels-screen--channel .page-header .icon-btn,
|
||||||
.channels-screen--thread .page-header .icon-btn {
|
.channels-screen--thread .page-header .icon-btn {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -5761,6 +5801,11 @@ textarea.input {
|
|||||||
transform: translateY(-40%);
|
transform: translateY(-40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar .icon-btn {
|
||||||
|
transform: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-screen--channel .page-header .channel-header-route-btn,
|
.channels-screen--channel .page-header .channel-header-route-btn,
|
||||||
.channels-screen--thread .page-header .channel-header-route-btn {
|
.channels-screen--thread .page-header .channel-header-route-btn {
|
||||||
border: 1px solid rgba(146, 173, 229, 0.38);
|
border: 1px solid rgba(146, 173, 229, 0.38);
|
||||||
@@ -5768,6 +5813,15 @@ textarea.input {
|
|||||||
color: #d9e6ff;
|
color: #d9e6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .page-header.channel-thread-topbar .channel-header-route-btn {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-screen--channel .page-header .channel-header-entrypoint-btn {
|
.channels-screen--channel .page-header .channel-header-entrypoint-btn {
|
||||||
border: 1px solid rgba(224, 190, 117, 0.38);
|
border: 1px solid rgba(224, 190, 117, 0.38);
|
||||||
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
|
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# SHiNE Solana Arweave Viewer
|
||||||
|
|
||||||
|
Основной рабочий файл:
|
||||||
|
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
Подробное описание работы, параметров и готовых ссылок:
|
||||||
|
|
||||||
|
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||||
|
|
||||||
|
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# SHiNE Solana Arweave Viewer
|
||||||
|
|
||||||
|
Основной рабочий файл:
|
||||||
|
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
Подробное описание работы, параметров и готовых ссылок:
|
||||||
|
|
||||||
|
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||||
|
|
||||||
|
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
|||||||
|
# SHiNE Solana Arweave Viewer: как это работает
|
||||||
|
|
||||||
|
## Что лежит в папке
|
||||||
|
|
||||||
|
Основной и единственный рабочий файл viewer:
|
||||||
|
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
Это автономный single-file viewer:
|
||||||
|
|
||||||
|
- весь HTML внутри одного файла;
|
||||||
|
- все стили внутри этого же файла;
|
||||||
|
- весь JavaScript внутри этого же файла;
|
||||||
|
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
|
||||||
|
|
||||||
|
Для запуска viewer достаточно открыть один `index.html`.
|
||||||
|
|
||||||
|
## Что делает viewer
|
||||||
|
|
||||||
|
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
|
||||||
|
|
||||||
|
Он умеет:
|
||||||
|
|
||||||
|
- принимать логин пользователя SHiNE;
|
||||||
|
- принимать `Solana RPC` через форму или через URL-параметр;
|
||||||
|
- принимать `Arweave gateway` через форму или через URL-параметр;
|
||||||
|
- сохранять эти значения в `localStorage`;
|
||||||
|
- вычислять `user_pda` по логину для программы `shine_users`;
|
||||||
|
- читать `PDA` напрямую из `Solana`;
|
||||||
|
- распарсивать содержимое записи `shine_users`;
|
||||||
|
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
|
||||||
|
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
|
||||||
|
|
||||||
|
## Какие URL-параметры поддерживаются
|
||||||
|
|
||||||
|
Viewer понимает такие параметры:
|
||||||
|
|
||||||
|
- `login`
|
||||||
|
- `solanaRpc`
|
||||||
|
- `arweaveGateway`
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
|
||||||
|
```
|
||||||
|
|
||||||
|
## Предустановленные адреса
|
||||||
|
|
||||||
|
### Solana RPC
|
||||||
|
|
||||||
|
- `https://solana-rpc.publicnode.com`
|
||||||
|
- `https://public.rpc.solanavibestation.com/`
|
||||||
|
|
||||||
|
### Arweave gateway
|
||||||
|
|
||||||
|
- `https://arweave.net`
|
||||||
|
|
||||||
|
## Почему одна из RPC может не работать
|
||||||
|
|
||||||
|
Адрес:
|
||||||
|
|
||||||
|
- `https://public.rpc.solanavibestation.com/`
|
||||||
|
|
||||||
|
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
|
||||||
|
|
||||||
|
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
|
||||||
|
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
|
||||||
|
|
||||||
|
Практически для viewer лучше использовать:
|
||||||
|
|
||||||
|
- `https://solana-rpc.publicnode.com`
|
||||||
|
|
||||||
|
## Ссылки на загруженный файл в Arweave
|
||||||
|
|
||||||
|
Основная ссылка:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
|
||||||
|
|
||||||
|
Та же ссылка с тестовым логином `aidartest`:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
|
||||||
|
|
||||||
|
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
|
||||||
|
|
||||||
|
## Что будет, если поставить devnet RPC
|
||||||
|
|
||||||
|
Официальный публичный `Solana devnet RPC`:
|
||||||
|
|
||||||
|
- `https://api.devnet.solana.com`
|
||||||
|
|
||||||
|
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
|
||||||
|
|
||||||
|
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
|
||||||
|
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
|
||||||
|
|
||||||
|
- программа `shine_users` должна быть задеплоена в `devnet`;
|
||||||
|
- в `devnet` должен существовать нужный `PDA` для логина;
|
||||||
|
- формат записи должен совпадать;
|
||||||
|
- данные пользователя должны реально лежать в этой тестовой сети.
|
||||||
|
|
||||||
|
Если этого нет, viewer просто не найдёт запись.
|
||||||
|
|
||||||
|
## Источник по devnet RPC
|
||||||
|
|
||||||
|
Официальная документация Solana:
|
||||||
|
|
||||||
|
- `https://solana.com/docs/references/clusters`
|
||||||
|
|
||||||
|
Там указан devnet endpoint:
|
||||||
|
|
||||||
|
- `https://api.devnet.solana.com`
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
|||||||
|
# SHiNE Solana Arweave Viewer: как это работает
|
||||||
|
|
||||||
|
## Что лежит в папке
|
||||||
|
|
||||||
|
Основной и единственный рабочий файл viewer:
|
||||||
|
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
Это автономный single-file viewer:
|
||||||
|
|
||||||
|
- весь HTML внутри одного файла;
|
||||||
|
- все стили внутри этого же файла;
|
||||||
|
- весь JavaScript внутри этого же файла;
|
||||||
|
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
|
||||||
|
|
||||||
|
Для запуска viewer достаточно открыть один `index.html`.
|
||||||
|
|
||||||
|
## Что делает viewer
|
||||||
|
|
||||||
|
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
|
||||||
|
|
||||||
|
Он умеет:
|
||||||
|
|
||||||
|
- принимать логин пользователя SHiNE;
|
||||||
|
- принимать `Solana RPC` через форму или через URL-параметр;
|
||||||
|
- принимать `Arweave gateway` через форму или через URL-параметр;
|
||||||
|
- сохранять эти значения в `localStorage`;
|
||||||
|
- вычислять `user_pda` по логину для программы `shine_users`;
|
||||||
|
- читать `PDA` напрямую из `Solana`;
|
||||||
|
- распарсивать содержимое записи `shine_users`;
|
||||||
|
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
|
||||||
|
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
|
||||||
|
|
||||||
|
## Какие URL-параметры поддерживаются
|
||||||
|
|
||||||
|
Viewer понимает такие параметры:
|
||||||
|
|
||||||
|
- `login`
|
||||||
|
- `solanaRpc`
|
||||||
|
- `arweaveGateway`
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
|
||||||
|
```
|
||||||
|
|
||||||
|
## Предустановленные адреса
|
||||||
|
|
||||||
|
### Solana RPC
|
||||||
|
|
||||||
|
- `https://solana-rpc.publicnode.com`
|
||||||
|
- `https://public.rpc.solanavibestation.com/`
|
||||||
|
|
||||||
|
### Arweave gateway
|
||||||
|
|
||||||
|
- `https://arweave.net`
|
||||||
|
|
||||||
|
## Почему одна из RPC может не работать
|
||||||
|
|
||||||
|
Адрес:
|
||||||
|
|
||||||
|
- `https://public.rpc.solanavibestation.com/`
|
||||||
|
|
||||||
|
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
|
||||||
|
|
||||||
|
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
|
||||||
|
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
|
||||||
|
|
||||||
|
Практически для viewer лучше использовать:
|
||||||
|
|
||||||
|
- `https://solana-rpc.publicnode.com`
|
||||||
|
|
||||||
|
## Ссылки на загруженный файл в Arweave
|
||||||
|
|
||||||
|
Основная ссылка:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
|
||||||
|
|
||||||
|
Та же ссылка с тестовым логином `aidartest`:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
|
||||||
|
|
||||||
|
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
|
||||||
|
|
||||||
|
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
|
||||||
|
|
||||||
|
## Что будет, если поставить devnet RPC
|
||||||
|
|
||||||
|
Официальный публичный `Solana devnet RPC`:
|
||||||
|
|
||||||
|
- `https://api.devnet.solana.com`
|
||||||
|
|
||||||
|
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
|
||||||
|
|
||||||
|
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
|
||||||
|
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
|
||||||
|
|
||||||
|
- программа `shine_users` должна быть задеплоена в `devnet`;
|
||||||
|
- в `devnet` должен существовать нужный `PDA` для логина;
|
||||||
|
- формат записи должен совпадать;
|
||||||
|
- данные пользователя должны реально лежать в этой тестовой сети.
|
||||||
|
|
||||||
|
Если этого нет, viewer просто не найдёт запись.
|
||||||
|
|
||||||
|
## Источник по devnet RPC
|
||||||
|
|
||||||
|
Официальная документация Solana:
|
||||||
|
|
||||||
|
- `https://solana.com/docs/references/clusters`
|
||||||
|
|
||||||
|
Там указан devnet endpoint:
|
||||||
|
|
||||||
|
- `https://api.devnet.solana.com`
|
||||||
Reference in New Issue
Block a user