SHA256
Compare commits
30
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 | ||
|
|
8c4e404f23 | ||
|
|
e8a4713f6e | ||
|
|
b4b23afc10 | ||
|
|
8f32e82d14 | ||
|
|
cf391bdd80 | ||
|
|
7120d6a9db | ||
|
|
4d80ab639e | ||
|
|
95bbd2852e | ||
|
|
4b0a934e51 | ||
|
|
ee5d86003b | ||
|
|
bc11b60147 | ||
|
|
801a4697ea | ||
|
|
628a970e6f | ||
|
|
566ea942dc | ||
|
|
e047c2d54e | ||
|
|
3fdd3d22e2 | ||
|
|
08e0fa9206 | ||
|
|
ed9e6ce676 | ||
|
|
a7bf07130e | ||
|
|
7c31662698 | ||
|
|
15df06a0bc | ||
|
|
47b5f8db7b | ||
|
|
0e763987aa | ||
|
|
37e8ea27d0 | ||
|
|
67d63256c2 | ||
|
|
ee185cf20a | ||
|
|
3552e05e4c |
@@ -160,11 +160,12 @@ public final class MsgSubType {
|
||||
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||
|
||||
public static final short STATUS_DONE_ONCE = 10;
|
||||
public static final short STATUS_INTERESTED = 20;
|
||||
public static final short STATUS_STARTED = 30;
|
||||
public static final short STATUS_IN_STUDY = 40;
|
||||
public static final short STATUS_COMPLETED = 50;
|
||||
public static final short STATUS_ABANDONED = 60;
|
||||
public static final short STATUS_LEARNED = 70;
|
||||
public static final short STATUS_CONFIRMED = 80;
|
||||
public static final short STATUS_LEARNED = 20;
|
||||
public static final short STATUS_SERVICE_PASSED = 30;
|
||||
public static final short STATUS_CONFIRMED = 100;
|
||||
public static final short STATUS_INTERESTED = 110;
|
||||
public static final short STATUS_STARTED = 120;
|
||||
public static final short STATUS_IN_STUDY = 130;
|
||||
public static final short STATUS_ABANDONED = 140;
|
||||
public static final short STATUS_COMPLETED = 150;
|
||||
}
|
||||
|
||||
+4
-3
@@ -127,13 +127,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
private static boolean isSupportedSubType(short subType) {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF);
|
||||
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
||||
}
|
||||
|
||||
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
|
||||
|
||||
@@ -21,10 +21,18 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_2 = 2;
|
||||
public static final int SCHEMA_VERSION_3 = 3;
|
||||
public static final int SCHEMA_VERSION_4 = 4;
|
||||
public static final 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_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||
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() {}
|
||||
|
||||
@@ -100,6 +108,22 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_4) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,14 @@ public final class MsgSubType {
|
||||
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||
|
||||
public static final short STATUS_DONE_ONCE = 10;
|
||||
public static final short STATUS_INTERESTED = 20;
|
||||
public static final short STATUS_STARTED = 30;
|
||||
public static final short STATUS_IN_STUDY = 40;
|
||||
public static final short STATUS_COMPLETED = 50;
|
||||
public static final short STATUS_ABANDONED = 60;
|
||||
public static final short STATUS_LEARNED = 70;
|
||||
public static final short STATUS_CONFIRMED = 80;
|
||||
public static final short STATUS_LEARNED = 20;
|
||||
public static final short STATUS_SERVICE_PASSED = 30;
|
||||
public static final short STATUS_CONFIRMED = 100;
|
||||
public static final short STATUS_INTERESTED = 110;
|
||||
public static final short STATUS_STARTED = 120;
|
||||
public static final short STATUS_IN_STUDY = 130;
|
||||
public static final short STATUS_ABANDONED = 140;
|
||||
public static final short STATUS_COMPLETED = 150;
|
||||
|
||||
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
||||
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||
|
||||
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) LIKE ?
|
||||
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||
AND (? IS NULL OR is_server = ?)
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
|
||||
List<CurrentUserEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
ps.setString(1, prefix.trim() + "%");
|
||||
if (isServer == null) {
|
||||
ps.setNull(2, Types.BOOLEAN);
|
||||
ps.setNull(3, Types.BOOLEAN);
|
||||
|
||||
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
|
||||
(
|
||||
SELECT
|
||||
current_users.login AS login,
|
||||
current_users.normalized_login AS normalized_login,
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
|
||||
@@ -53,12 +53,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -92,12 +96,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
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 (
|
||||
pda_address TEXT PRIMARY KEY,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
normalized_login TEXT NOT NULL,
|
||||
record_number INTEGER NOT NULL,
|
||||
slot BIGINT 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
|
||||
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 (
|
||||
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,
|
||||
@@ -162,12 +169,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -201,12 +212,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
@@ -432,7 +447,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state(updated_at_ms);
|
||||
|
||||
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),
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
msg_type INTEGER NOT NULL,
|
||||
@@ -462,7 +477,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
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,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
@@ -616,8 +631,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
target_mode INTEGER NOT NULL,
|
||||
target_session_id TEXT,
|
||||
message_type INTEGER NOT NULL,
|
||||
@@ -634,8 +649,8 @@ CREATE TABLE IF NOT EXISTS signed_messages (
|
||||
message_key TEXT PRIMARY KEY,
|
||||
base_key TEXT NOT NULL,
|
||||
target_login TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
nonce BIGINT NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
|
||||
+4
@@ -67,6 +67,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
@@ -75,6 +76,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
@@ -184,6 +186,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
@@ -265,6 +268,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
|
||||
+9
-1
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
|
||||
String serverAddress = safe(serverProfile.serverAddress());
|
||||
if (serverAddress.isBlank()) continue;
|
||||
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
||||
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
|
||||
}
|
||||
return new ArrayList<>(routes.values());
|
||||
}
|
||||
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
accessServers.add(normalizeLogin(accessServerLogin));
|
||||
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
||||
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
||||
continue;
|
||||
}
|
||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalizedAccessServerLogin);
|
||||
}
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
|
||||
+29
-8
@@ -170,6 +170,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST";
|
||||
case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION";
|
||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
default -> "Ошибка: " + code;
|
||||
@@ -405,15 +406,14 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if ((block.type & 0xFFFF) == 5
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
|
||||
if ((block.type & 0xFFFF) == 5) {
|
||||
try {
|
||||
String statusError = validateStatusConfirmedTarget(block);
|
||||
String statusError = validateStatusActionTarget(block);
|
||||
if (statusError != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: status_confirmed_target_check_failed (login={}, blockchainName={}, blockNumber={})",
|
||||
log.error("AddBlock: status_action_target_check_failed (login={}, blockchainName={}, blockNumber={})",
|
||||
login, blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
@@ -653,7 +653,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateStatusConfirmedTarget(BchBlockEntry block) throws Exception {
|
||||
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
|
||||
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
|
||||
String targetBch = statusBody.toBchName();
|
||||
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
|
||||
@@ -666,10 +666,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||
return null;
|
||||
}
|
||||
if (target.getMsgType() != 5) {
|
||||
return "status_confirmed_target_must_be_status_action";
|
||||
int statusSubType = block.subType & 0xFFFF;
|
||||
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
|
||||
if (target.getMsgType() != 5) {
|
||||
return "status_confirmed_target_must_be_status_action";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
|
||||
|
||||
int targetSubType = target.getMsgSubType();
|
||||
if (statusSubType == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||
|| statusSubType == (MsgSubType.STATUS_LEARNED & 0xFFFF)) {
|
||||
return targetSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||
}
|
||||
if (statusSubType == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)) {
|
||||
return targetSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||
}
|
||||
if (statusSubType == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||
|| statusSubType == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||
|| statusSubType == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||
|| statusSubType == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||
|| statusSubType == (MsgSubType.STATUS_COMPLETED & 0xFFFF)) {
|
||||
return targetSubType == (MsgSubType.TEXT_COURSE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||
}
|
||||
return "status_action_target_not_allowed";
|
||||
}
|
||||
|
||||
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||
|
||||
+21
-5
@@ -6,6 +6,8 @@ import java.util.Map;
|
||||
public final class ChannelMetaTextParser {
|
||||
public static final int MAX_TITLE_CHARS = 50;
|
||||
public static final int MAX_DESCRIPTION_CHARS = 250;
|
||||
private static final String LEGACY_PREFIX = "<SHiNE:";
|
||||
private static final String SHORT_PREFIX = "<S:";
|
||||
|
||||
private ChannelMetaTextParser() {}
|
||||
|
||||
@@ -19,15 +21,16 @@ public final class ChannelMetaTextParser {
|
||||
boolean seenAvatar = false;
|
||||
|
||||
int offset = 0;
|
||||
while (text.startsWith("<SHiNE:", offset)) {
|
||||
while (startsWithTagPrefix(text, offset)) {
|
||||
int end = text.indexOf('>', offset);
|
||||
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 (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||
seenTitle = true;
|
||||
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");
|
||||
seenAvatar = true;
|
||||
Avatar avatar = parseAvatarTag(body);
|
||||
@@ -61,11 +64,14 @@ public final class ChannelMetaTextParser {
|
||||
}
|
||||
|
||||
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");
|
||||
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
||||
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
||||
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
|
||||
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 (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
|
||||
long size;
|
||||
@@ -78,6 +84,16 @@ public final class ChannelMetaTextParser {
|
||||
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) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||
|
||||
+29
-2
@@ -309,15 +309,42 @@ final class ChannelsReadSupport {
|
||||
|
||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
int likesCount = 0;
|
||||
int repliesCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
ps.setInt(2, blockNumber);
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new int[] {0, 0};
|
||||
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
|
||||
if (rs.next()) {
|
||||
likesCount = rs.getInt("likes_count");
|
||||
repliesCount = rs.getInt("replies_count");
|
||||
}
|
||||
}
|
||||
}
|
||||
String ratingsSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM blocks
|
||||
WHERE msg_type = ?
|
||||
AND msg_sub_type = ?
|
||||
AND to_bch_name = ?
|
||||
AND to_block_number = ?
|
||||
AND to_block_hash = ?
|
||||
""";
|
||||
int ratingsCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
|
||||
ps.setInt(1, MSG_TYPE_TEXT);
|
||||
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||
ps.setString(3, bch);
|
||||
ps.setInt(4, blockNumber);
|
||||
ps.setBytes(5, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
ratingsCount = rs.getInt(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new int[] {likesCount, repliesCount, ratingsCount};
|
||||
}
|
||||
|
||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setRatingsCount(stats[2]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
|
||||
items.add(item);
|
||||
|
||||
+22
-11
@@ -17,6 +17,7 @@ import shine.db.DbController;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.Comparator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
@@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
|
||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
||||
if (depthDown <= 0) return List.of();
|
||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||
List<PostRow> replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||
for (PostRow row : replies) {
|
||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE msg_type=1 AND msg_sub_type=?
|
||||
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
ORDER BY block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
||||
ps.setString(2, toBchName);
|
||||
ps.setInt(3, toBlockNumber);
|
||||
ps.setBytes(4, toBlockHash);
|
||||
ps.setInt(5, limit);
|
||||
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||
ps.setString(3, toBchName);
|
||||
ps.setInt(4, toBlockNumber);
|
||||
ps.setBytes(5, toBlockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostRow> out = new ArrayList<>();
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
out.sort(Comparator
|
||||
.comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs)
|
||||
.thenComparing(row -> String.valueOf(row.bchName))
|
||||
.thenComparingInt(row -> row.blockNumber));
|
||||
if (out.size() > limit) {
|
||||
return new ArrayList<>(out.subList(0, limit));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
first.setCreatedAtMs(base.createdAtMs);
|
||||
versions.add(first);
|
||||
|
||||
if (row.msgSubType == MsgSubType.TEXT_REPLY || ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
|
||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
||||
if (row.msgSubType == MsgSubType.TEXT_REPLY
|
||||
|| row.msgSubType == MsgSubType.TEXT_RATING
|
||||
|| ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
|
||||
short editType = (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_RATING)
|
||||
? MsgSubType.TEXT_EDIT_REPLY
|
||||
: MsgSubType.TEXT_EDIT_POST;
|
||||
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
||||
@@ -233,6 +243,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setRatingsCount(stats[2]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
if (row.lineCode != null && row.lineCode >= 0) {
|
||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetPersonalDiary_Handler.class);
|
||||
private static final String DIARY_CHANNEL_NAME = "diary";
|
||||
private static final String DIARY_DISPLAY_NAME = "Личный дневник";
|
||||
private static final String DIARY_DESCRIPTION = "История ваших действий по упражнениям, услугам и курсам";
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetPersonalDiary_Request req = (Net_GetPersonalDiary_Request) baseRequest;
|
||||
String requestedLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim();
|
||||
if (requestedLogin.isBlank() && (ctx == null || ctx.getLogin() == null || ctx.getLogin().isBlank())) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 200 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
}
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String viewerLogin = ctx != null ? String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim() : "";
|
||||
String canonicalLogin = !viewerLogin.isBlank()
|
||||
? viewerLogin
|
||||
: ChannelsReadSupport.canonicalLogin(c, requestedLogin);
|
||||
if (canonicalLogin == null || canonicalLogin.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
|
||||
}
|
||||
if (!viewerLogin.isBlank() && !viewerLogin.equalsIgnoreCase(canonicalLogin)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "forbidden", "Личный дневник доступен только владельцу");
|
||||
}
|
||||
|
||||
String ownerBch = loadPrimaryBlockchainName(c, canonicalLogin);
|
||||
if (ownerBch == null || ownerBch.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "blockchain_not_found", "Не найден blockchain пользователя");
|
||||
}
|
||||
|
||||
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
|
||||
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
|
||||
channel.setOwnerLogin(canonicalLogin);
|
||||
channel.setOwnerBlockchainName(ownerBch);
|
||||
channel.setChannelName(DIARY_CHANNEL_NAME);
|
||||
channel.setDisplayName(DIARY_DISPLAY_NAME);
|
||||
channel.setChannelDescription(DIARY_DESCRIPTION);
|
||||
channel.setChannelTypeCode(900);
|
||||
channel.setChannelTypeVersion(1);
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
rootRef.setBlockNumber(0);
|
||||
rootRef.setBlockHash(ChannelsReadSupport.toHex(new byte[32]));
|
||||
channel.setChannelRoot(rootRef);
|
||||
resp.setChannel(channel);
|
||||
resp.setMetaEvents(new ArrayList<>());
|
||||
|
||||
List<Net_GetChannelMessages_Response.MessageItem> items = loadDiaryItems(c, canonicalLogin, limit, asc);
|
||||
resp.setMessages(items);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetPersonalDiary failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private String loadPrimaryBlockchainName(Connection c, String canonicalLogin) throws Exception {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT blockchain_name
|
||||
FROM blockchain_state
|
||||
WHERE login = ?
|
||||
ORDER BY blockchain_name
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, canonicalLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("blockchain_name") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetChannelMessages_Response.MessageItem> loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||
FROM blocks
|
||||
WHERE login = ? AND msg_type = ?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?")) {
|
||||
ps.setString(1, canonicalLogin);
|
||||
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_STATUS_ACTION);
|
||||
ps.setInt(3, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||
BchBlockEntry entry = new BchBlockEntry(blockBytes);
|
||||
if (!(entry.body instanceof StatusActionBody statusBody)) continue;
|
||||
|
||||
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
|
||||
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
||||
ref.setBlockNumber(rs.getInt("block_number"));
|
||||
ref.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
|
||||
item.setMessageRef(ref);
|
||||
item.setMsgSubType(rs.getInt("msg_sub_type"));
|
||||
item.setAuthorLogin(rs.getString("login"));
|
||||
item.setAuthorBlockchainName(rs.getString("bch_name"));
|
||||
item.setCreatedAtMs(entry.timestamp * 1000L);
|
||||
item.setLikesCount(0);
|
||||
item.setLikedByMe(false);
|
||||
item.setRepliesCount(0);
|
||||
item.setRatingsCount(0);
|
||||
item.setTargetBlockchainName(statusBody.toBchName());
|
||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||
c,
|
||||
rs.getString("bch_name"),
|
||||
rs.getInt("block_number"),
|
||||
rs.getBytes("block_hash"),
|
||||
statusBody.message == null ? "" : statusBody.message,
|
||||
entry.timestamp * 1000L
|
||||
);
|
||||
item.setVersions(versions);
|
||||
item.setVersionsTotal(versions.size());
|
||||
item.setText(versions.get(versions.size() - 1).getText());
|
||||
|
||||
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
|
||||
out.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Net_GetChannelMessages_Response.VersionItem> loadVersionsForDiaryItem(Connection c,
|
||||
String ownerBch,
|
||||
int originalBlockNumber,
|
||||
byte[] originalBlockHash,
|
||||
String originalText,
|
||||
long originalCreatedAtMs) throws Exception {
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
|
||||
|
||||
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
|
||||
first.setVersionIndex(1);
|
||||
first.setBlockNumber(originalBlockNumber);
|
||||
first.setBlockHash(ChannelsReadSupport.toHex(originalBlockHash));
|
||||
first.setText(originalText == null ? "" : originalText);
|
||||
first.setCreatedAtMs(originalCreatedAtMs);
|
||||
versions.add(first);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT block_number, block_hash, block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name = ?
|
||||
AND msg_type = ?
|
||||
AND msg_sub_type = ?
|
||||
AND to_block_number = ?
|
||||
AND to_block_hash = ?
|
||||
AND (to_bch_name = ? OR to_bch_name IS NULL OR to_bch_name = '')
|
||||
ORDER BY block_number ASC
|
||||
""")) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||
ps.setInt(3, shine.db.MsgSubType.TEXT_EDIT_REPLY);
|
||||
ps.setInt(4, originalBlockNumber);
|
||||
ps.setBytes(5, originalBlockHash);
|
||||
ps.setString(6, ownerBch);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
|
||||
Net_GetChannelMessages_Response.VersionItem item = new Net_GetChannelMessages_Response.VersionItem();
|
||||
item.setVersionIndex(versions.size() + 1);
|
||||
item.setBlockNumber(rs.getInt("block_number"));
|
||||
item.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
|
||||
item.setText(editText.text);
|
||||
item.setCreatedAtMs(editText.createdAtMs);
|
||||
versions.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
private void fillTargetDetails(Connection c,
|
||||
Net_GetChannelMessages_Response.MessageItem item,
|
||||
String targetBch,
|
||||
Integer targetBlockNumber,
|
||||
byte[] targetHash) throws Exception {
|
||||
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
||||
return;
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, targetBch);
|
||||
ps.setInt(2, targetBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return;
|
||||
byte[] actualHash = rs.getBytes("block_hash");
|
||||
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
|
||||
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
|
||||
item.setTargetAuthorLogin(rs.getString("login"));
|
||||
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
|
||||
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
|
||||
item.setTargetText(textInfo.text);
|
||||
item.setTargetCreatedAtMs(textInfo.createdAtMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -127,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
private Integer targetMsgSubType;
|
||||
private String targetText;
|
||||
private String targetAuthorLogin;
|
||||
private String targetAuthorBlockchainName;
|
||||
private Long targetCreatedAtMs;
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private boolean likedByMe;
|
||||
private int repliesCount;
|
||||
private int ratingsCount;
|
||||
private int versionsTotal;
|
||||
private List<VersionItem> versions = new ArrayList<>();
|
||||
|
||||
@@ -158,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
|
||||
public Integer getTargetMsgSubType() { return targetMsgSubType; }
|
||||
public void setTargetMsgSubType(Integer targetMsgSubType) { this.targetMsgSubType = targetMsgSubType; }
|
||||
|
||||
public String getTargetText() { return targetText; }
|
||||
public void setTargetText(String targetText) { this.targetText = targetText; }
|
||||
|
||||
public String getTargetAuthorLogin() { return targetAuthorLogin; }
|
||||
public void setTargetAuthorLogin(String targetAuthorLogin) { this.targetAuthorLogin = targetAuthorLogin; }
|
||||
|
||||
public String getTargetAuthorBlockchainName() { return targetAuthorBlockchainName; }
|
||||
public void setTargetAuthorBlockchainName(String targetAuthorBlockchainName) { this.targetAuthorBlockchainName = targetAuthorBlockchainName; }
|
||||
|
||||
public Long getTargetCreatedAtMs() { return targetCreatedAtMs; }
|
||||
public void setTargetCreatedAtMs(Long targetCreatedAtMs) { this.targetCreatedAtMs = targetCreatedAtMs; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
|
||||
@@ -173,6 +194,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public int getRepliesCount() { return repliesCount; }
|
||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||
|
||||
public int getRatingsCount() { return ratingsCount; }
|
||||
public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; }
|
||||
|
||||
public int getVersionsTotal() { return versionsTotal; }
|
||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetPersonalDiary_Request extends Net_Request {
|
||||
private String login;
|
||||
private Integer limit;
|
||||
private String sort;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public String getSort() { return sort; }
|
||||
public void setSort(String sort) { this.sort = sort; }
|
||||
}
|
||||
+77
-43
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class PostgresStorageRepository
|
||||
implements AutoCloseable {
|
||||
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
|
||||
|
||||
String sql =
|
||||
"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, " +
|
||||
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
||||
"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, " +
|
||||
"prev_record_hash, record_signature, raw_data_base64, " +
|
||||
"first_seen_at_ms, last_synced_at_ms" +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
||||
"login = EXCLUDED.login, " +
|
||||
"normalized_login = EXCLUDED.normalized_login, " +
|
||||
"record_number = EXCLUDED.record_number, " +
|
||||
"slot = EXCLUDED.slot, " +
|
||||
"last_tx_signature = EXCLUDED.last_tx_signature, " +
|
||||
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
|
||||
|
||||
statement.setString(1, snapshot.pdaAddress());
|
||||
statement.setString(2, snapshot.login());
|
||||
statement.setInt(3, snapshot.recordNumber());
|
||||
statement.setLong(4, snapshot.slot());
|
||||
statement.setString(5, snapshot.lastTxSignature());
|
||||
statement.setString(6, snapshot.recoveryKey());
|
||||
statement.setString(7, snapshot.rootKey());
|
||||
statement.setString(8, snapshot.clientKey());
|
||||
statement.setString(9, snapshot.blockchainName());
|
||||
statement.setString(10, snapshot.blockchainKey());
|
||||
statement.setLong(11, snapshot.paidLimitBytes());
|
||||
statement.setLong(12, snapshot.usedBytes());
|
||||
statement.setInt(13, snapshot.lastBlockNumber());
|
||||
statement.setString(14, snapshot.lastBlockHash());
|
||||
statement.setString(15, snapshot.lastBlockSignature());
|
||||
statement.setString(16, snapshot.arweaveTxId());
|
||||
statement.setBoolean(17, snapshot.isServer());
|
||||
statement.setInt(18, snapshot.addressFormatType());
|
||||
statement.setInt(19, snapshot.addressFormatVersion());
|
||||
statement.setString(20, snapshot.serverAddress());
|
||||
statement.setString(21, writeJson(snapshot.syncServers()));
|
||||
statement.setString(22, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(23, snapshot.sessionsMode());
|
||||
statement.setString(24, writeJson(snapshot.sessions()));
|
||||
statement.setInt(25, snapshot.trustedCount());
|
||||
statement.setLong(26, snapshot.createdAtMs());
|
||||
statement.setLong(27, snapshot.updatedAtMs());
|
||||
statement.setString(28, snapshot.prevRecordHash());
|
||||
statement.setString(29, snapshot.recordSignature());
|
||||
statement.setString(30, snapshot.rawDataBase64());
|
||||
statement.setLong(31, nowMs);
|
||||
statement.setString(3, normalizeLogin(snapshot.login()));
|
||||
statement.setInt(4, snapshot.recordNumber());
|
||||
statement.setLong(5, snapshot.slot());
|
||||
statement.setString(6, snapshot.lastTxSignature());
|
||||
statement.setString(7, snapshot.recoveryKey());
|
||||
statement.setString(8, snapshot.rootKey());
|
||||
statement.setString(9, snapshot.clientKey());
|
||||
statement.setString(10, snapshot.blockchainName());
|
||||
statement.setString(11, snapshot.blockchainKey());
|
||||
statement.setLong(12, snapshot.paidLimitBytes());
|
||||
statement.setLong(13, snapshot.usedBytes());
|
||||
statement.setInt(14, snapshot.lastBlockNumber());
|
||||
statement.setString(15, snapshot.lastBlockHash());
|
||||
statement.setString(16, snapshot.lastBlockSignature());
|
||||
statement.setString(17, snapshot.arweaveTxId());
|
||||
statement.setBoolean(18, snapshot.isServer());
|
||||
statement.setInt(19, snapshot.addressFormatType());
|
||||
statement.setInt(20, snapshot.addressFormatVersion());
|
||||
statement.setString(21, snapshot.serverAddress());
|
||||
statement.setString(22, writeJson(snapshot.syncServers()));
|
||||
statement.setString(23, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(24, snapshot.sessionsMode());
|
||||
statement.setString(25, writeJson(snapshot.sessions()));
|
||||
statement.setInt(26, snapshot.trustedCount());
|
||||
statement.setLong(27, snapshot.createdAtMs());
|
||||
statement.setLong(28, snapshot.updatedAtMs());
|
||||
statement.setString(29, snapshot.prevRecordHash());
|
||||
statement.setString(30, snapshot.recordSignature());
|
||||
statement.setString(31, snapshot.rawDataBase64());
|
||||
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(
|
||||
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
|
||||
"pda_address TEXT PRIMARY KEY, " +
|
||||
"login TEXT NOT NULL UNIQUE, " +
|
||||
"normalized_login TEXT NOT NULL, " +
|
||||
"record_number INTEGER NOT NULL, " +
|
||||
"slot BIGINT NOT NULL, " +
|
||||
"last_tx_signature TEXT NOT NULL, " +
|
||||
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
|
||||
"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(
|
||||
"CREATE INDEX IF NOT EXISTS idx_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(
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
|
||||
@@ -1057,12 +1083,16 @@ public final class PostgresStorageRepository
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
JOIN solana_user_pda_current s
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -1100,12 +1130,16 @@ public final class PostgresStorageRepository
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||
WHERE ord <= 2
|
||||
) AS access_server
|
||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Подключение других устройств по QR и типизированные сессии
|
||||
|
||||
## Зачем
|
||||
|
||||
QR-подключение других устройств сейчас есть как заготовка, но сценарий нужно довести до устойчивого состояния. Параллельно надо аккуратно оформить типизированные сессии homeserver-ов в PDA.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Довести QR-сценарий до стабильного подключения нового устройства.
|
||||
2. Нормально описать и хранить устройство как отдельную типизированную сессию.
|
||||
3. Согласовать это с серверной и UI-логикой.
|
||||
4. Проверить, что подключение работает одинаково на новом и повторном устройстве.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- в планах есть `сессионные homeserver-ы в PDA`;
|
||||
- в планах есть `подключение других устройств через QR`;
|
||||
- базовая заготовка уже существует, но сценарий считается нестабильным.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- от текущих документов в `TODO/medium/`;
|
||||
- отдельно проверить, какие поля уже есть в PDA и UI.
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `docs/Solana_Architecture/README.md`;
|
||||
- `TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md`;
|
||||
- `TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md`.
|
||||
@@ -1,28 +0,0 @@
|
||||
# ESP32 как личное файловое хранилище
|
||||
|
||||
## Зачем
|
||||
|
||||
Планируется использовать ESP32 как личное файловое хранилище SHiNE для переписок и вложений.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Продумать формат хранения файлов на устройстве.
|
||||
2. Согласовать загрузку и чтение файлов между UI, сервером и устройством.
|
||||
3. Проверить, как устройство показывает статусы и ошибки.
|
||||
4. Свести это с существующим homeserver/UI-прототипом.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- в списке будущих фич уже есть отдельная задача по ESP32S3 file storage;
|
||||
- для UI homeserver уже есть отдельная документация и скетч должны держаться синхронно.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- от `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
||||
- от документации по ESP32 UI homeserver.
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
||||
- `TODO/README.md`;
|
||||
- документацию по ESP32 UI homeserver, если добавятся экраны или статусы.
|
||||
@@ -1,61 +0,0 @@
|
||||
# TODO
|
||||
|
||||
Папка для короткого списка ближайших и среднесрочных задач, которые уже обсуждались и пока отложены.
|
||||
|
||||
## Как использовать
|
||||
|
||||
- Один markdown-файл = одна задача.
|
||||
- В файле коротко фиксируем:
|
||||
- зачем это нужно;
|
||||
- что именно сделать;
|
||||
- что уже есть в коде;
|
||||
- откуда продолжать;
|
||||
- какие документы потом надо обновить.
|
||||
- Это не активная разработка. Тут только план и контекст.
|
||||
- Старую папку `docs/Future_Features/` считать архивной и больше не использовать как источник новых задач.
|
||||
|
||||
## Текущие задачи
|
||||
|
||||
- `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом.
|
||||
- `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии.
|
||||
- `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений.
|
||||
|
||||
## Децентрализация
|
||||
|
||||
Текущий production-режим SHiNE считается односерверным. Задачи по нескольким серверам, Arweave и realtime PDA/Solana sync вынесены в `Децентрализация/` и не блокируют выкладку текущей версии на GitHub.
|
||||
|
||||
- `Децентрализация/односерверный_production_режим.md` - границы текущей production-версии с одним сервером.
|
||||
- `Децентрализация/запись_блокчейнов_в_arweave.md` - будущая запись/архивация блокчейнов в Arweave.
|
||||
- `Децентрализация/realtime_pda_solana_sync.md` - будущая онлайн-синхронизация PDA и Solana.
|
||||
- `Децентрализация/межсерверная_передача_сообщений.md` - будущая доставка сообщений между серверами.
|
||||
- `Децентрализация/межсерверные_звонки.md` - будущая маршрутизация звонков между серверами.
|
||||
- `Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - перенесённый старый план постоянного server-to-server WS и DM sync.
|
||||
|
||||
## Новые фишки которые надо доделать
|
||||
|
||||
- `Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/` - отложенная новая контентная модель блокчейна, не входящая в текущий односерверный production-релиз.
|
||||
|
||||
## Перенесённые планы из `docs/Future_Features/`
|
||||
|
||||
### near
|
||||
|
||||
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
||||
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
||||
|
||||
### medium
|
||||
|
||||
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
||||
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||
- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload.
|
||||
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
|
||||
|
||||
### dao_запуск
|
||||
|
||||
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
||||
|
||||
### far
|
||||
|
||||
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
|
||||
|
||||
## Зачем нужна фича
|
||||
|
||||
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
|
||||
|
||||
Цели:
|
||||
- отправлять на homeserver технические команды в текстовом виде;
|
||||
- получать текстовый ответ на команду;
|
||||
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
|
||||
- хранить полученные файлы на SD-карте homeserver;
|
||||
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
|
||||
|
||||
## Горизонт
|
||||
|
||||
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
|
||||
|
||||
## Что именно имеется в виду
|
||||
|
||||
### 1. Единая модель технической команды
|
||||
|
||||
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
|
||||
- через любой доступный сервер SHiNE;
|
||||
- через уже установленный WebRTC DataChannel.
|
||||
|
||||
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
|
||||
|
||||
### 2. Команда как короткоживущий подписанный сигнал
|
||||
|
||||
У команды должны быть:
|
||||
- `commandId`;
|
||||
- временная метка;
|
||||
- TTL около 10 секунд;
|
||||
- криптографическая подпись.
|
||||
|
||||
Смысл такой:
|
||||
- если команда быстро дошла, homeserver подтверждает принятие;
|
||||
- если не дошла вовремя, команда считается протухшей;
|
||||
- отправитель может безопасно послать повтор;
|
||||
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
|
||||
|
||||
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
|
||||
|
||||
### 3. Текстовые технические команды
|
||||
|
||||
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
|
||||
- отправил строку-команду;
|
||||
- получил строку-ответ.
|
||||
|
||||
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
|
||||
|
||||
### 4. Передача файлов только при наличии DataChannel
|
||||
|
||||
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
|
||||
|
||||
Предварительная модель:
|
||||
- имя файла = `SHA-256` содержимого;
|
||||
- можно запросить диапазон байт `from..to`;
|
||||
- можно отправить диапазон байт `from..to`;
|
||||
- homeserver хранит полученные данные на SD-карте;
|
||||
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
|
||||
|
||||
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
|
||||
|
||||
### 5. Установка data-соединения по явной команде
|
||||
|
||||
Нужна техническая команда уровня:
|
||||
- `установить data-соединение`.
|
||||
|
||||
Ответ:
|
||||
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
|
||||
- либо `нет` и причина отказа.
|
||||
|
||||
### 6. Доставка на пользовательские сессии
|
||||
|
||||
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
|
||||
|
||||
Идея:
|
||||
- на любую активную сессию пользователя можно посылать техническую команду;
|
||||
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
|
||||
|
||||
## Что нужно будет сделать при возврате к задаче
|
||||
|
||||
- Спроектировать отдельный формат технических команд и ack-ответов.
|
||||
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
|
||||
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
|
||||
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
|
||||
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
|
||||
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
|
||||
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
|
||||
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
|
||||
|
||||
## Вопросы для будущего уточнения
|
||||
|
||||
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
|
||||
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
|
||||
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
|
||||
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
|
||||
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
|
||||
|
||||
## Какие документы нужно будет обновить при реализации
|
||||
|
||||
- `docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
|
||||
- `docs/API/` если изменится публичный серверный API или появятся новые операции.
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
|
||||
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
|
||||
|
||||
## С какого места продолжать позже
|
||||
|
||||
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Кошелёк и пополнение баланса сияния
|
||||
|
||||
- Горизонт:
|
||||
`medium`
|
||||
- Ориентир:
|
||||
среднесрочно
|
||||
- Статус:
|
||||
`proposal`
|
||||
|
||||
## Кратко
|
||||
|
||||
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
|
||||
|
||||
## Предполагаемый сценарий
|
||||
|
||||
1. Пользователь регистрируется и получает/подключает нужные кошельки.
|
||||
2. В интерфейсе появляется баланс сияния.
|
||||
3. Пользователь открывает пополнение баланса сияния.
|
||||
4. Система создаёт или принимает блокчейн-операцию пополнения.
|
||||
5. После подтверждения баланса UI обновляет значение.
|
||||
|
||||
## Что нужно продумать
|
||||
|
||||
1. Что именно является единицей баланса сияния.
|
||||
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
|
||||
3. Какая операция отвечает за пополнение.
|
||||
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
|
||||
5. Как баланс восстанавливается после перезагрузки клиента.
|
||||
6. Какие права нужны для пополнения и списания.
|
||||
7. Нужна ли история операций баланса.
|
||||
|
||||
## Вопросы перед реализацией
|
||||
|
||||
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
|
||||
2. Нужна ли конвертация из SOL/AR в сияние.
|
||||
3. Кто может выпускать или начислять сияние.
|
||||
4. Нужно ли поддерживать перевод сияния между пользователями.
|
||||
5. Нужны ли лимиты, комиссии или статусы подтверждения.
|
||||
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
|
||||
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
|
||||
|
||||
## Важное ограничение
|
||||
|
||||
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
|
||||
|
||||
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
|
||||
|
||||
## Документы, которые обновить при реализации
|
||||
|
||||
- `docs/Blockchain/`, если появятся или изменятся блоки баланса.
|
||||
- `docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
|
||||
- `docs/API/`, если меняется серверный API.
|
||||
- после реализации отдельно согласовать ручную проверку.
|
||||
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
|
||||
|
||||
## Минимальная проверка в будущем
|
||||
|
||||
1. Новый пользователь видит корректный начальный баланс.
|
||||
2. Пополнение создаёт правильную операцию.
|
||||
3. Баланс обновляется после подтверждения.
|
||||
4. После перезагрузки UI баланс остаётся корректным.
|
||||
5. Ошибочные или повторные операции не начисляют баланс дважды.
|
||||
@@ -1,44 +0,0 @@
|
||||
# ESP32S3 как личное файловое хранилище SHiNE
|
||||
|
||||
## Горизонт
|
||||
|
||||
Среднесрочный: ближайшие недели или 1-2 месяца.
|
||||
|
||||
## Зачем нужна фича
|
||||
|
||||
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
|
||||
|
||||
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
|
||||
|
||||
## Что нужно сделать
|
||||
|
||||
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
|
||||
- Определить, какие ключи может хранить такое устройство.
|
||||
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
|
||||
- Описать протокол загрузки, скачивания и удаления файлов.
|
||||
- Определить правила шифрования файлов до отправки на устройство.
|
||||
- Продумать индексацию файлов для личных и общих переписок.
|
||||
- Решить, как устройство авторизуется на основном сервере SHiNE.
|
||||
|
||||
## Вопросы перед реализацией
|
||||
|
||||
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
|
||||
- Нужен ли внешний relay, если устройство находится за NAT?
|
||||
- Какие ограничения по размеру файла считаем допустимыми?
|
||||
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
|
||||
- Как восстанавливать доступ, если устройство потеряно или заменено?
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
|
||||
|
||||
## Документы, которые нужно обновить при возврате
|
||||
|
||||
- `docs/Keys/README.md`
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`
|
||||
- `docs/API/`
|
||||
- `docs/Blockchain/`, если появятся новые блоки или команды для файлов.
|
||||
|
||||
## С какого места продолжать
|
||||
|
||||
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Сессионные homeserver-ы в PDA пользователя
|
||||
|
||||
- Статус:
|
||||
`future`
|
||||
|
||||
- Горизонт:
|
||||
`medium`
|
||||
|
||||
- Ориентир:
|
||||
после завершения первого этапа по пользовательским сессиям
|
||||
|
||||
- Основание:
|
||||
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
|
||||
|
||||
## Зачем нужна фича
|
||||
|
||||
У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
|
||||
|
||||
Это нужно, чтобы:
|
||||
|
||||
- хранить несколько homeserver-ов у одного пользователя одновременно;
|
||||
- различать обычные клиентские сессии и серверные сессии по явному типу;
|
||||
- дать расширяемый формат записи с версией;
|
||||
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
|
||||
|
||||
## Целевая идея
|
||||
|
||||
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
|
||||
|
||||
- `sessionType` (`u8`);
|
||||
- `sessionVersion` (`u8`);
|
||||
- `sessionName`;
|
||||
- `sessionPubKey`.
|
||||
|
||||
Предварительные значения:
|
||||
|
||||
- тип `1` - обычная пользовательская сессия;
|
||||
- тип `100` - homeserver пользователя;
|
||||
- версия `1` - первая рабочая версия формата записи сессии.
|
||||
|
||||
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
|
||||
|
||||
Важно: homeserver-ов у одного пользователя может быть несколько.
|
||||
|
||||
## Архитектурный принцип
|
||||
|
||||
Внутренний протокол взаимодействия должен оставаться транспортным.
|
||||
|
||||
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен:
|
||||
|
||||
- доставлять сообщения между сессиями;
|
||||
- доставлять сигналы звонков между сессиями;
|
||||
- хранить и маршрутизировать адресацию;
|
||||
- не принимать на себя бизнес-логику содержимого внутренних команд.
|
||||
|
||||
## Что уже подтверждается текущим кодом
|
||||
|
||||
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
|
||||
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
|
||||
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
|
||||
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
|
||||
|
||||
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а".
|
||||
|
||||
## Что нужно сделать при возврате к задаче
|
||||
|
||||
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
|
||||
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
|
||||
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
|
||||
4. Решить, где именно хранится массив сессий:
|
||||
- в основной записи пользователя;
|
||||
- в отдельной PDA-структуре расширения;
|
||||
- или в смешанной схеме с базовой записью и внешними индексами.
|
||||
5. Зафиксировать ограничения:
|
||||
- максимальное число сессий;
|
||||
- максимальную длину `sessionName`;
|
||||
- правила удаления и обновления записи;
|
||||
- правила ротации `sessionPubKey`.
|
||||
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
|
||||
7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
|
||||
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
|
||||
9. После реализации обязательно обновить документацию.
|
||||
|
||||
## Что нужно обновить при реализации
|
||||
|
||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||
- `docs/Solana_Architecture/README.md`
|
||||
- `docs/Инициализация_Solana_регистрации/README.md`
|
||||
- `docs/Keys/README.md`
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий
|
||||
- `docs/API/`, если появятся новые серверные операции или изменятся ответы
|
||||
|
||||
## Что пока не делать
|
||||
|
||||
- Не включать это автоматически в основной deploy сервера.
|
||||
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
|
||||
- Не добавлять временные поля в публичный API "на всякий случай".
|
||||
|
||||
## С какого места продолжать
|
||||
|
||||
Продолжать после завершения первой части:
|
||||
|
||||
1. описать минимальный формат записи пользовательской сессии;
|
||||
2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии;
|
||||
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Подключение других устройств через QR
|
||||
|
||||
- Горизонт:
|
||||
`medium`
|
||||
- Ориентир:
|
||||
позже, не сейчас
|
||||
- Статус:
|
||||
`future`
|
||||
|
||||
## Зачем нужна фича
|
||||
|
||||
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- В UI уже есть экраны:
|
||||
- `shine-UI/js/pages/connect-device-view.js`
|
||||
- `shine-UI/js/pages/device-qr-view.js`
|
||||
- Есть сервис переноса ключей через QR:
|
||||
- `shine-UI/js/services/qr-key-transfer-service.js`
|
||||
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
|
||||
|
||||
## Что нужно будет сделать потом
|
||||
|
||||
1. Проверить и довести формат QR-передачи.
|
||||
2. Проверить сканирование и ручной ввод QR-текста.
|
||||
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
|
||||
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
|
||||
5. Отдельно проверить сценарий без `BarcodeDetector`.
|
||||
6. Довести экран подтверждения на втором устройстве.
|
||||
|
||||
## Что сейчас важно
|
||||
|
||||
- Не считать эту часть готовой.
|
||||
- Не возвращать её в активную разработку без отдельной команды пользователя.
|
||||
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
|
||||
|
||||
## Что обновить при возврате
|
||||
|
||||
- после реализации отдельно согласовать ручную проверку
|
||||
- `shine-UI/js/pages/connect-device-view.js`
|
||||
- `shine-UI/js/pages/device-qr-view.js`
|
||||
- `shine-UI/js/services/qr-key-transfer-service.js`
|
||||
- документацию по ключам, если формат переноса меняется
|
||||
@@ -1,29 +0,0 @@
|
||||
# Перенести старые сессионные сигналы на `SendSignal`
|
||||
|
||||
## Контекст
|
||||
|
||||
В проект добавлен новый общий межсессионный transport `SendSignal`.
|
||||
|
||||
Первое текущее применение:
|
||||
|
||||
- `remote AddBlock via homeserver session`
|
||||
|
||||
Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код.
|
||||
|
||||
## Что перенести позже
|
||||
|
||||
1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`.
|
||||
2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту.
|
||||
3. Остальные доверенные межсессионные команды одного пользователя.
|
||||
|
||||
## Что важно учесть при переносе
|
||||
|
||||
- не ломать обратную совместимость работающих звонков;
|
||||
- сохранить текущую маршрутизацию по `sessionId`;
|
||||
- договориться о едином `signalType`;
|
||||
- отдельно описать миграцию клиентских обработчиков событий:
|
||||
- `IncomingCallSignal` -> `IncomingSignal`
|
||||
|
||||
## С какого сценария продолжать
|
||||
|
||||
Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Переход с SQLite на PostgreSQL
|
||||
|
||||
## Зачем
|
||||
|
||||
Переход runtime-сервера на `PostgreSQL` уже выполнен, но после него остались хвосты в документации, именах, комментариях и части прямых SQL-запросов.
|
||||
|
||||
Этот TODO теперь нужен не для самого перехода, а для доведения проекта до полностью консистентного состояния после ухода от `SQLite`.
|
||||
|
||||
## Что сделать
|
||||
|
||||
- Дочистить документацию, где ещё описан `SQLite` как текущий runtime.
|
||||
- Убрать или переименовать legacy-названия и комментарии, которые уже не соответствуют PostgreSQL runtime.
|
||||
- Постепенно перенести оставшиеся прямые SQL-запросы из хэндлеров в DAO/service.
|
||||
- Проверить case-insensitive сравнения, уникальные ограничения и индексы уже в чисто PostgreSQL модели.
|
||||
- Отдельно пройтись по TODO/служебным документам и убрать ссылки на удалённые SQLite-классы как на актуальный код.
|
||||
|
||||
## Что уже есть в коде
|
||||
|
||||
- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту.
|
||||
- Основная серверная логика уже разделена по модулям.
|
||||
- Runtime-сервер уже работает только с `PostgreSQL`.
|
||||
- Пустая БД инициализируется автоматически через `schema_v1`.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- Продолжать с зачистки legacy-документации и комментариев.
|
||||
- Затем добрать оставшиеся прямые SQL-запросы вне DAO.
|
||||
- После этого можно отдельно решать вопрос косметического переименования `*V2`, `DbController` и других переходных сущностей.
|
||||
|
||||
## Что потом обновить
|
||||
|
||||
- Серверную документацию по БД и миграциям.
|
||||
- Инструкции по локальному запуску сервера.
|
||||
- Скрипты деплоя и настройки окружения.
|
||||
|
||||
## Что временно отключено и что вернуть потом
|
||||
|
||||
- В серверном runtime временно снята проверка `channelName must not contain only digits`
|
||||
в `SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java`.
|
||||
- Причина: на боевой истории уже есть блоки с числовыми именами каналов, и сервер
|
||||
должен уметь с нуля восстановить `blockchain_state` и `.bch`, подтягивая старые
|
||||
блоки от других sync-серверов.
|
||||
- Что осталось как текущее поведение:
|
||||
- UI по-прежнему не даёт создать новый канал только из цифр;
|
||||
- сервер принимает такие имена, чтобы не ломать replay старых блоков.
|
||||
- Что нужно сделать отдельным следующим шагом:
|
||||
- вернуть серверное продуктовое правило для новых каналов;
|
||||
- сделать это совместимо со старой историей, чтобы импорт/реплей существующих
|
||||
блоков не падал на старых числовых channel name.
|
||||
- Какие документы обновить при возврате:
|
||||
- `docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md`;
|
||||
- UI/серверные документы по правилам имён каналов, если появится отдельная спецификация.
|
||||
- С какого сценария продолжать:
|
||||
- повторить cold start тест на `t2`: пустая PostgreSQL schema, удалённые `.bch`,
|
||||
новый запуск, ожидание полной синхронизации от `t1`/`t3`.
|
||||
- Последняя полная рабочая точка с этим временным компромиссом:
|
||||
- ветка `migration-postgres`, коммит будет создан после этой записи.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Пополнение Solana и Arweave через внешний сервис покупки
|
||||
|
||||
- Горизонт:
|
||||
`near`
|
||||
- Ориентир:
|
||||
сегодня/завтра
|
||||
- Статус:
|
||||
`proposal`
|
||||
|
||||
## Кратко
|
||||
|
||||
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
|
||||
|
||||
## Пользовательский сценарий
|
||||
|
||||
1. Пользователь видит адрес кошелька Solana или Arweave.
|
||||
2. Нажимает `Пополнить`.
|
||||
3. Открывается промежуточное окно с инструкцией:
|
||||
- сейчас пользователь перейдёт на страницу покупки/пополнения;
|
||||
- нужно указать или проверить адрес кошелька;
|
||||
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
|
||||
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
|
||||
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
|
||||
4. В окне есть кнопки:
|
||||
- `Скопировать адрес и перейти`;
|
||||
- `Перейти без копирования`.
|
||||
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
|
||||
|
||||
## Что нужно сделать
|
||||
|
||||
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
|
||||
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
|
||||
3. Выбрать международный сервис покупки Solana с карты, не российский.
|
||||
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
|
||||
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
|
||||
6. Добавить отдельные действия для Solana и Arweave.
|
||||
7. Сделать текст инструкции коротким и понятным.
|
||||
8. Проверить, что адрес копируется в буфер обмена в браузере.
|
||||
9. Проверить мобильный сценарий и desktop-сценарий.
|
||||
|
||||
## Вопросы перед реализацией
|
||||
|
||||
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
|
||||
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
|
||||
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
|
||||
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
|
||||
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
|
||||
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
|
||||
7. Какой точный текст использовать для времени прихода Arweave.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
|
||||
- Clipboard API может требовать HTTPS и пользовательский жест.
|
||||
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
|
||||
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
|
||||
|
||||
## Документы, которые обновить при реализации
|
||||
|
||||
- Документацию UI/кошельков, если такая есть.
|
||||
- после реализации отдельно согласовать ручную проверку.
|
||||
- `docs/API/`, только если появится новый серверный API или логирование.
|
||||
|
||||
## Минимальная проверка
|
||||
|
||||
1. На Solana-кошельке открывается правильное окно пополнения.
|
||||
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
|
||||
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
|
||||
4. Аналогичный сценарий работает для Arweave.
|
||||
5. На мобильном экране текст и кнопки не перекрываются.
|
||||
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Убрать временную очистку `signed_messages_v2` в миграции БД v11
|
||||
|
||||
## Зачем это нужно
|
||||
|
||||
При переходе на новый DM-протокол `SHiNE_DM v1` была добавлена временная миграция БД `v11`, которая при первом старте на старой базе полностью очищает:
|
||||
|
||||
- `signed_messages_v2`
|
||||
- `signed_message_session_delivery`
|
||||
|
||||
Это сделано как защитный reset, потому что старые DM-строки и backlog могли быть несовместимы с новым форматом, новой логикой tombstone и новым клиентским E2EE-разбором.
|
||||
|
||||
## Что именно потом сделать
|
||||
|
||||
- найти историческое место, где была добавлена временная миграция `migrateToV11()`, и убрать её остатки из runtime-логики/документации;
|
||||
- удалить helper `clearLegacySignedMessagesForDmV11(...)`;
|
||||
- поднять версию схемы дальше обычным образом уже без destructive-cleanup;
|
||||
- при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории.
|
||||
|
||||
## Что уже есть в коде
|
||||
|
||||
- `LATEST_SCHEMA_VERSION = 11`;
|
||||
- при миграции в `v11` выполняется полная очистка DM-таблиц;
|
||||
- в коде прямо оставлен комментарий, что это временная мера.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
Продолжать от коммита, в котором была добавлена миграция `v11` для очистки DM-таблиц после перехода на `SHiNE_DM v1`.
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории;
|
||||
- `docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации;
|
||||
- при необходимости `docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки.
|
||||
+8
@@ -1,5 +1,13 @@
|
||||
# Восстановить полную логику `shine_login_guard`
|
||||
|
||||
|
||||
Тоесть сделать что бы нормально проверялись логины пользователей
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Статус: отложено.
|
||||
|
||||
## Зачем это нужно
|
||||
@@ -0,0 +1 @@
|
||||
Передачу билетов владельцами со счёта на счёт
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Баланс в салане
|
||||
|
||||
баланс в Арвив / и турбо
|
||||
|
||||
Балан лимит МБ / оно же сияния токены SHN
|
||||
@@ -0,0 +1,3 @@
|
||||
Сделать поддержку нескольких залогиненных аккаунтов враз тоесть что бы можно было менять акаунт под которым заходить
|
||||
- подумать о деталях, а так хорошая тема - и тестировать удобнее станет
|
||||
-
|
||||
-10
@@ -27,13 +27,3 @@
|
||||
|
||||
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
|
||||
- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- начать с `systemd`-юнита и базового shutdown-hook в сервере;
|
||||
- затем проверить, что текущие операции реально завершаются в отведённые 30 секунд.
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `deploy/`;
|
||||
- `docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
Там были какието разные апи для ошибок звонка
|
||||
и для старта тестовых соединений
|
||||
|
||||
убрать короче лишнее
|
||||
|
||||
|
||||
и сделать номальное апи что бы клиент мог
|
||||
высылать уведомления об ошибках на сервер
|
||||
предлогал отправить уведомление
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Сделать что бы если сервера с исходящими сообщениями не могут доставить на входящие то
|
||||
- делать повторные попытки через время
|
||||
- если так и не получилось и никужа не доставлено уведомлять пользователя
|
||||
|
||||
- Так же как вариант собирать подписи что полученно входящее сообщение с серверов пользователя полчателя
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Как_вариант_можно_сделать_hameserver_как_хранилище_файлов_пользователя
|
||||
И всё это можно сделать внутри ESP32
|
||||
|
||||
Хотя не понятно надо ли так делать - потому что вроде удобно,
|
||||
но тем не менее и сложно как то объяснить такой функционал людям
|
||||
@@ -1,43 +0,0 @@
|
||||
# Постоянный server-to-server WS и DM sync
|
||||
|
||||
## Зачем
|
||||
|
||||
Текущий production-режим SHiNE рассчитан на один основной сервер. Межсерверная синхронизация относится к будущей децентрализации и не должна блокировать выкладку односерверной production-версии.
|
||||
|
||||
Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации в будущем ещё нужен постоянный межсерверный канал:
|
||||
|
||||
- живое подключение к партнёру;
|
||||
- push новых блоков;
|
||||
- push DM;
|
||||
- ACK на доставку;
|
||||
- backoff/reconnect;
|
||||
- стартовый backfill.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Поднять постоянное WebSocket-соединение между партнёрскими серверами.
|
||||
2. Сделать push новых блоков сразу после `AddBlock`.
|
||||
3. Сделать push DM-блоков между серверами.
|
||||
4. Добавить ACK и повторную отправку при сбое.
|
||||
5. Ввести стартовый обмен курсорами и добор хвоста.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- `ListBlockchainHeads`;
|
||||
- `GetBlockchainBlock`;
|
||||
- `GetSyncUserProfile`;
|
||||
- базовый periodic sync;
|
||||
- базовый backfill хвоста;
|
||||
- базовый full resync при divergence.
|
||||
|
||||
## Откуда продолжать
|
||||
|
||||
- от текущего `sync_servers` bootstrap и `PeriodicBlockchainSyncService`;
|
||||
- дальше выделить отдельный межсерверный transport layer.
|
||||
|
||||
## Какие документы потом обновить
|
||||
|
||||
- `docs/Blockchain/sync-between-servers.md`;
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`;
|
||||
- `docs/Personal_Messages/Формат_DM_v1.md`;
|
||||
- `docs/API/`.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Децентрализация
|
||||
|
||||
Папка для задач, которые нужны для будущего режима с несколькими серверами, Solana/PDA-синхронизацией и внешним хранением данных.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Сейчас production-режим SHiNE считается односерверным: один сервер обслуживает пользователей, сообщения, звонки и запись данных. Задачи из этой папки не являются блокерами для выкладки текущего репозитория на GitHub и запуска одного production-сервера.
|
||||
|
||||
## Задачи
|
||||
|
||||
- `односерверный_production_режим.md` - зафиксировать границы текущей production-версии.
|
||||
- `запись_блокчейнов_в_arweave.md` - вынести долговременную запись блокчейнов в Arweave.
|
||||
- `realtime_pda_solana_sync.md` - сделать онлайн-синхронизацию PDA/Solana в реальном времени.
|
||||
- `межсерверная_передача_сообщений.md` - реализовать доставку сообщений между серверами.
|
||||
- `межсерверные_звонки.md` - реализовать маршрутизацию звонков между серверами.
|
||||
- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - старый план постоянного server-to-server WS и DM sync, перенесённый в контекст децентрализации.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Realtime-синхронизация PDA и Solana
|
||||
|
||||
## Зачем
|
||||
|
||||
В будущем PDA-записи и Solana-состояние должны автоматически и быстро синхронизироваться с серверным состоянием, чтобы данные пользователей, homeserver-сессии и связанные записи не расходились.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Определить, какие серверные события должны обновлять PDA.
|
||||
2. Добавить очередь/воркер для надёжной отправки изменений в Solana.
|
||||
3. Добавить периодическую сверку серверного состояния с PDA.
|
||||
4. Добавить обработку ошибок, повторов и конфликтов версий.
|
||||
5. Добавить мониторинг задержек и неуспешных Solana-транзакций.
|
||||
|
||||
## Что учесть
|
||||
|
||||
- Solana/Anchor-модуль находится в `shine-solana/shine/` и ведётся отдельно от основного server/UI deploy.
|
||||
- Перед изменениями внутри Solana-модуля нужно читать `shine-solana/shine/AGENTS.md`.
|
||||
- Основная инструкция по Solana-регистрации находится в `docs/Инициализация_Solana_регистрации/README.md`.
|
||||
- Формат пользовательской PDA-записи описан в `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
|
||||
|
||||
## Документы, которые потом нужно обновить
|
||||
|
||||
- `docs/Инициализация_Solana_регистрации/README.md`;
|
||||
- `docs/Solana_Architecture/README.md`;
|
||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`, если меняется формат PDA.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено до этапа децентрализации.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Запись блокчейнов в Arweave
|
||||
|
||||
## Зачем
|
||||
|
||||
Для будущей децентрализации нужно долговременное внешнее хранение блокчейнов, чтобы данные не зависели только от одного серверного диска.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Определить, какие блокчейны и какие диапазоны блоков записываются в Arweave.
|
||||
2. Зафиксировать формат пачки блоков, метаданных, ссылок и контрольных хэшей.
|
||||
3. Добавить безопасный механизм публикации без хранения приватного JWK в git.
|
||||
4. Добавить проверку уже загруженных диапазонов, чтобы не плодить дубли.
|
||||
5. Описать восстановление блокчейна из Arweave при потере локальных данных.
|
||||
|
||||
## Важные ограничения
|
||||
|
||||
- Любое изменение формата блокчейна требует отдельного предупреждения и явного подтверждения пользователя.
|
||||
- Добавление данных в блокчейн должно выполняться только через `AddBlock`.
|
||||
- Секреты Arweave нельзя хранить в репозитории.
|
||||
|
||||
## Документы, которые потом нужно обновить
|
||||
|
||||
- `docs/Blockchain/README.md`;
|
||||
- `docs/Blockchain/CHANGELOG.md`;
|
||||
- документы deploy/секретов в `deploy/`, если появятся новые параметры.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено до этапа децентрализации.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Межсерверная передача сообщений
|
||||
|
||||
## Зачем
|
||||
|
||||
Когда у SHiNE появится несколько серверов, пользователи на разных серверах должны получать личные сообщения без ручной синхронизации и без привязки к одному центральному узлу.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Определить протокол server-to-server доставки DM.
|
||||
2. Добавить маршрутизацию получателя по серверу, user id, публичному ключу или PDA.
|
||||
3. Добавить ACK, повторы, дедупликацию и backfill пропущенных сообщений.
|
||||
4. Разделить realtime-доставку и восстановление истории.
|
||||
5. Описать поведение при недоступности удалённого сервера.
|
||||
|
||||
## Что учесть
|
||||
|
||||
- Логика DM должна соответствовать документам в `docs/Personal_Messages/`.
|
||||
- При изменении формата signed DM-блока или правил доставки нужно обновлять протокол и байтовый формат DM.
|
||||
- Если появятся новые server API/WebSocket операции, нужно обновить `docs/API/`.
|
||||
|
||||
## Документы, которые потом нужно обновить
|
||||
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`;
|
||||
- `docs/Personal_Messages/Формат_DM_v1.md`;
|
||||
- `docs/API/`;
|
||||
- `docs/API/09_Operations_Index.md`, если добавляются новые `op`.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено до этапа децентрализации.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Межсерверные звонки
|
||||
|
||||
## Зачем
|
||||
|
||||
В будущем пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Определить протокол межсерверной сигнализации звонков.
|
||||
2. Добавить маршрутизацию offer/answer/ICE-кандидатов между серверами.
|
||||
3. Добавить обработку статусов занятости, отказа, таймаута и ошибок маршрута.
|
||||
4. Добавить диагностику доставки сигналов между серверами.
|
||||
5. Проверить совместимость с текущими логами `CallDeliveryReport`.
|
||||
|
||||
## Что учесть
|
||||
|
||||
- Специальная диагностика установки звонков идёт через `CallDeliveryReport`.
|
||||
- На production важно сохранять поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
|
||||
- Межсерверные звонки не должны ломать текущий односерверный сценарий.
|
||||
|
||||
## Документы, которые потом нужно обновить
|
||||
|
||||
- `docs/API/`, если добавляются или меняются операции сигнализации;
|
||||
- документы по звонкам/диагностике, если они будут выделены отдельно;
|
||||
- deploy-документы, если появятся новые параметры TURN/server-to-server маршрутизации.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено до этапа децентрализации.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Односерверный production-режим
|
||||
|
||||
## Зачем
|
||||
|
||||
Перед выкладкой репозитория на GitHub и запуском production нужно явно зафиксировать, что текущая стабильная версия работает как один основной сервер.
|
||||
|
||||
## Что считаем текущей нормой
|
||||
|
||||
- Один production-сервер обслуживает пользователей, сообщения, звонки и серверные данные.
|
||||
- Децентрализованные сценарии не считаются обязательными для первого production-релиза.
|
||||
- Межсерверная доставка сообщений, межсерверные звонки, realtime PDA/Solana sync и запись блокчейнов в Arweave вынесены в отдельные будущие задачи.
|
||||
- Код и документация текущего production не должны создавать ожидание, что несколько серверов уже работают как единая realtime-сеть.
|
||||
|
||||
## Что сделать перед возвратом к децентрализации
|
||||
|
||||
1. Проверить актуальные документы по API, blockchain, DM и deploy.
|
||||
2. Выделить минимальный протокол server-to-server взаимодействия.
|
||||
3. Решить, какие данные остаются локальными, какие реплицируются между серверами, а какие записываются во внешнее долговременное хранилище.
|
||||
4. После изменения API, blockchain-форматов или DM-протокола обновить соответствующие документы по правилам проекта.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено. Текущий production работает как один сервер.
|
||||
@@ -0,0 +1,6 @@
|
||||
Сделать все эти: друг, близкий друг, и статусы типо сияет, точно сияет, реальный человек и т.д.
|
||||
|
||||
и как вариант не реальный человек тк
|
||||
- украли акаунт
|
||||
- сразу был нереальным мошенником
|
||||
- умер
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
# Новая логика контента в блокчейне SHiNE
|
||||
|
||||
## Зачем это нужно
|
||||
|
||||
Сейчас блокчейн SHiNE хорошо умеет хранить обычные сообщения, ответы, лайки и связи между людьми.
|
||||
|
||||
Новая модель добавляет поверх этого более понятный смысл контента:
|
||||
|
||||
- обычный текст;
|
||||
- упражнение;
|
||||
- услуга / процедура;
|
||||
- курс;
|
||||
- стартовая страница канала (`entrypoint`).
|
||||
|
||||
Это нужно для того, чтобы канал стал не просто лентой постов, а полноценным пространством знаний, практик, услуг и сообществ.
|
||||
|
||||
## Что меняется для людей
|
||||
|
||||
### 1. В канале появятся понятные виды материалов
|
||||
|
||||
Сообщение можно будет создать не только как обычный текст, но и как:
|
||||
|
||||
- упражнение;
|
||||
- услугу / процедуру;
|
||||
- курс;
|
||||
- стартовую страницу канала.
|
||||
|
||||
Смысл в том, что приложение и сервер будут понимать, что это за материал, а не просто показывать любой текст одинаково.
|
||||
|
||||
### 2. У канала будет стартовая страница
|
||||
|
||||
У канала появится отдельное стартовое сообщение `entrypoint`.
|
||||
|
||||
Это не курс и не оглавление, а именно главная точка входа в канал:
|
||||
|
||||
- короткое объяснение, о чём канал;
|
||||
- описание структуры;
|
||||
- ссылки на нужные материалы;
|
||||
- удобное начало для новых людей.
|
||||
|
||||
У канала в каждый момент времени будет только одна актуальная стартовая страница.
|
||||
Если её исправляют, то сохраняется история версий.
|
||||
Если её удаляют, для интерфейса считается, что стартовой страницы у канала сейчас нет.
|
||||
|
||||
### 3. Курс, упражнение и услуга / процедура будут отличаться по смыслу
|
||||
|
||||
Это важно для логики и статистики.
|
||||
|
||||
- `Упражнение` — то, что человек может делать много раз.
|
||||
- `Услуга / процедура` — то, что тоже можно проходить много раз, но обычно с участием другого человека.
|
||||
- `Курс` — то, что можно начать, закончить или бросить.
|
||||
|
||||
За счёт этого сервер сможет честно считать активность, а интерфейс сможет показывать человеку именно те действия, которые подходят к данному типу материала.
|
||||
|
||||
### 4. Появятся статусные действия
|
||||
|
||||
На контент можно будет не только ответить или поставить лайк, но и отметить свой путь:
|
||||
|
||||
- сделал один раз;
|
||||
- заинтересовался и рассматривает;
|
||||
- начал;
|
||||
- закончил / освоил / знаю;
|
||||
- бросил.
|
||||
|
||||
При этом:
|
||||
|
||||
- для упражнений и услуг / процедур будет отдельно считаться, сколько раз человек сделал / прошёл;
|
||||
- для упражнений и курсов будет храниться текущий статус.
|
||||
|
||||
Текущий статус определяется просто:
|
||||
|
||||
- последнее статусное действие и считается актуальным.
|
||||
|
||||
Например:
|
||||
|
||||
- если последнее действие “заинтересовался и рассматривает”, значит человек присматривается, но ещё не начал;
|
||||
- если последнее действие `started`, значит материал сейчас в процессе;
|
||||
- если последнее действие `abandoned`, значит человек бросил;
|
||||
- если последнее действие `completed`, значит для системы он завершил / освоил материал.
|
||||
|
||||
### 5. К действиям можно добавлять живой текст
|
||||
|
||||
Практически любое статусное действие можно будет сопровождать коротким комментарием.
|
||||
|
||||
Например:
|
||||
|
||||
- “Начал изучать, потому что давно хотел разобраться”;
|
||||
- “Бросил, пока нет времени”;
|
||||
- “Прошёл процедуру, стало заметно легче”.
|
||||
|
||||
Это важно, потому что сам блокчейн будет хранить не только формальный статус, но и живую человеческую причину или заметку.
|
||||
|
||||
### 6. Появится подтверждение статуса другими людьми
|
||||
|
||||
Отдельный человек сможет подтвердить чей-то статус.
|
||||
|
||||
Примеры:
|
||||
|
||||
- подтвердить, что человек действительно занимался;
|
||||
- подтвердить, что он реально прошёл услугу;
|
||||
- подтвердить, что он освоил материал.
|
||||
|
||||
Подтверждение — это не замена статуса, а отдельное мнение / свидетельство со стороны.
|
||||
|
||||
### 7. Появится отдельный тип «мнение»
|
||||
|
||||
На любое сообщение можно будет ответить не только обычным ответом, но и специальным типом ответа: `мнение`.
|
||||
|
||||
Это по сути тоже текстовый ответ, но с отдельным смыслом:
|
||||
|
||||
- это отзыв;
|
||||
- это оценка;
|
||||
- это мнение о материале;
|
||||
- это явная метка для будущего анализа нейронками.
|
||||
|
||||
То есть:
|
||||
|
||||
- обычный ответ нужен для разговора;
|
||||
- `мнение` нужно для отзыва, оценки и анализа реакции людей.
|
||||
|
||||
## Что остаётся как раньше
|
||||
|
||||
### Комментарии
|
||||
|
||||
Обычные ответы на сообщения остаются.
|
||||
То есть обсуждение материалов не ломается и не меняется концептуально.
|
||||
|
||||
### Лайки контента
|
||||
|
||||
Лайк на сообщение, курс, упражнение или услугу остаётся обычной реакцией на конкретный блок.
|
||||
|
||||
### Лайк пользователю
|
||||
|
||||
Лайк пользователю не будет считаться реакцией на сообщение.
|
||||
Он относится к графу связей между людьми.
|
||||
|
||||
Это удобно, потому что:
|
||||
|
||||
- лайк человека — это отношение к человеку;
|
||||
- лайк материала — это отношение к контенту.
|
||||
|
||||
## Сообщество вокруг канала
|
||||
|
||||
Канал сможет работать не только как лента, но и как сообщество.
|
||||
|
||||
Для этого появятся простые действия:
|
||||
|
||||
- заявка на вступление;
|
||||
- самостоятельный выход;
|
||||
- принятие;
|
||||
- исключение.
|
||||
|
||||
Сервер сможет понимать:
|
||||
|
||||
- кто только подал заявку;
|
||||
- кто уже принят;
|
||||
- кто вышел;
|
||||
- кто был исключён.
|
||||
|
||||
## Личный канал и лента достижений
|
||||
|
||||
У каждого человека по смыслу появляется два важных пространства:
|
||||
|
||||
- канал его обычных постов;
|
||||
- отдельная лента его тренировок и достижений.
|
||||
|
||||
В обычном канале человек сможет:
|
||||
|
||||
- писать посты;
|
||||
- делиться мыслями;
|
||||
- публиковать материалы;
|
||||
- обсуждать темы как раньше.
|
||||
|
||||
А в ленте достижений будут видны его реальные действия:
|
||||
|
||||
- какие упражнения он делал;
|
||||
- какие услуги / процедуры проходил;
|
||||
- какие курсы его заинтересовали;
|
||||
- какие курсы он начал;
|
||||
- какие курсы он закончил;
|
||||
- что он бросил.
|
||||
|
||||
То есть блокчейн SHiNE сможет хранить не только слова человека, но и его путь, активность и историю практики.
|
||||
|
||||
## Что смогут делать авторы контента
|
||||
|
||||
Создатели контента в своих каналах смогут публиковать не только обычные посты, но и:
|
||||
|
||||
- упражнения;
|
||||
- курсы;
|
||||
- стартовую страницу канала;
|
||||
- услуги / процедуры, которые они оказывают.
|
||||
|
||||
Это превращает канал в сочетание:
|
||||
|
||||
- блога;
|
||||
- базы знаний;
|
||||
- пространства обучения;
|
||||
- каталога услуг и практик.
|
||||
|
||||
## Что увидит человек в интерфейсе
|
||||
|
||||
На специальных сообщениях в UI можно будет показывать отдельные кнопки действий.
|
||||
|
||||
Например:
|
||||
|
||||
- `Выполнил упражнение`
|
||||
- `Прошёл процедуру`
|
||||
- `Заинтересовало`
|
||||
- `Начал курс`
|
||||
- `Закончил курс`
|
||||
|
||||
То есть материал можно будет не просто прочитать, а сразу отметить реальное действие.
|
||||
|
||||
Также при ответе на любое сообщение можно будет выбрать:
|
||||
|
||||
- обычный ответ;
|
||||
- `мнение / отзыв`.
|
||||
|
||||
## Как будет работать лента достижений
|
||||
|
||||
Если кто-то зайдёт в твою ленту достижений, он сможет:
|
||||
|
||||
- прочитать, что ты делал;
|
||||
- оставить мнение / отзыв;
|
||||
- подтвердить, что это действительно было.
|
||||
|
||||
Это даёт основу для мягкой “сертификации” внутри SHiNE.
|
||||
|
||||
Например:
|
||||
|
||||
- человек прошёл курс и получил подтверждения;
|
||||
- человек прошёл процедуру и получил отзыв;
|
||||
- человек регулярно делает упражнения, и это видно в его истории.
|
||||
|
||||
Так постепенно у пользователя появляется не только лента постов, но и лента достижений, подтверждений и репутации.
|
||||
|
||||
## Ссылки внутри SHiNE
|
||||
|
||||
Для переходов между материалами вводятся простые внутренние адреса:
|
||||
|
||||
- обычная ссылка: `SHiNE/alice-001/157`
|
||||
- особополная ссылка: `SHiNE/alice-001/157/ХЭШ`
|
||||
|
||||
Первая форма — основная и каноническая.
|
||||
Вторая нужна там, где хочется добавить ещё и точную проверку по хэшу.
|
||||
|
||||
## Что это даёт в итоге
|
||||
|
||||
После внедрения новая блокчейн-логика позволит:
|
||||
|
||||
- строить каналы как структурированные пространства, а не просто как поток постов;
|
||||
- выделять упражнения, услуги и курсы как отдельные сущности;
|
||||
- показывать стартовую страницу канала;
|
||||
- хранить путь человека по материалу;
|
||||
- хранить отдельную ленту его действий и достижений;
|
||||
- считать активность и статусы;
|
||||
- подтверждать результаты другими людьми;
|
||||
- развивать сообщество вокруг канала.
|
||||
|
||||
И самое важное: всё это можно добавить как расширение уже существующего блокчейна SHiNE, не разрушая старую модель сообщений.
|
||||
|
||||
## Отдельный вопрос для будущего
|
||||
|
||||
Отзывы о людях как о людях — полезная идея, но её стоит дополнительно обдумать.
|
||||
|
||||
Например, на вкладке связей в будущем можно:
|
||||
|
||||
- писать человеку отзыв;
|
||||
- смотреть все отзывы о человеке;
|
||||
- выводить сначала отзывы близких друзей, родственников, друзей и контактов, а уже потом остальные.
|
||||
|
||||
Но этот слой нужно делать осторожно, чтобы он не стал слишком жёстким или неприятным для людей.
|
||||
|
||||
Поэтому отзывы о людях как отдельная социальная механика требуют дополнительного обсуждения и проектирования.
|
||||
-670
@@ -1,670 +0,0 @@
|
||||
# ТЗ: новая контентная модель блокчейна SHiNE
|
||||
|
||||
## Статус документа
|
||||
|
||||
Этот документ описывает предлагаемые новые типы блоков и правила их обработки.
|
||||
|
||||
Цель:
|
||||
|
||||
- добавить новую семантику контента;
|
||||
- не ломать существующие блоки `type=0..4`;
|
||||
- внедрить всё как расширение блокчейна за счёт новых форматов.
|
||||
|
||||
Документ является проектным ТЗ на реализацию в сервере, БД, API чтения и UI.
|
||||
|
||||
## 1. Базовые принципы
|
||||
|
||||
### 1.1. Совместимость
|
||||
|
||||
Старые типы не меняются:
|
||||
|
||||
- `type=0` — TECH
|
||||
- `type=1` — TEXT
|
||||
- `type=2` — REACTION
|
||||
- `type=3` — CONNECTION
|
||||
- `type=4` — USER_PARAM
|
||||
|
||||
Новые сущности и действия добавляются только как новые `type` и новые `body`.
|
||||
|
||||
Это означает:
|
||||
|
||||
- старые блоки продолжают читаться как раньше;
|
||||
- старые `TEXT_POST`, `TEXT_REPLY`, `REACTION_LIKE` и остальные форматы не ломаются;
|
||||
- существующий блокчейн остаётся валидным;
|
||||
- новый функционал появляется только там, где клиент и сервер умеют его понимать.
|
||||
|
||||
### 1.2. Общая стратегия
|
||||
|
||||
Новая модель делится на четыре слоя:
|
||||
|
||||
1. контентные сущности;
|
||||
2. текстовые отзывы и мнения;
|
||||
3. статусные действия пользователей;
|
||||
4. community-события вокруг канала.
|
||||
|
||||
### 1.3. Редактирование и удаление
|
||||
|
||||
Для новых контентных сущностей сохраняется действующий принцип SHiNE:
|
||||
|
||||
- редактирование всегда ссылается на оригинальный блок;
|
||||
- тип сущности edit не меняет;
|
||||
- удаление выполняется через `edit` с пустым текстом;
|
||||
- отдельный `DELETE`-подтип не вводится.
|
||||
|
||||
Это правило особенно важно для:
|
||||
|
||||
- `plain_text`
|
||||
- `exercise`
|
||||
- `service`
|
||||
- `course`
|
||||
- `entrypoint`
|
||||
|
||||
В пользовательских текстах и UI желательно использовать русские названия:
|
||||
|
||||
- обычный текст;
|
||||
- упражнение;
|
||||
- услуга / процедура;
|
||||
- курс;
|
||||
- стартовое сообщение канала.
|
||||
|
||||
## 2. Канонические внутренние ссылки
|
||||
|
||||
В новой модели поддерживаются только две формы внутренней ссылки:
|
||||
|
||||
- каноническая: `SHiNE/<blockchainName>/<blockNumber>`
|
||||
- особополная: `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
|
||||
|
||||
Примеры:
|
||||
|
||||
- `SHiNE/alice-001/157`
|
||||
- `SHiNE/alice-001/157/abcd1234...`
|
||||
|
||||
Правила:
|
||||
|
||||
- канонической считается именно короткая форма без хэша;
|
||||
- форма с хэшем используется как усиленный вариант для точной проверки;
|
||||
- внутри UI и серверной логики ссылка должна приводиться как минимум к паре:
|
||||
- `blockchainName`
|
||||
- `blockNumber`
|
||||
- если хэш присутствует, он участвует в дополнительной валидации ссылки.
|
||||
|
||||
## 3. Новые контентные сущности
|
||||
|
||||
## 3.1. Новый `type=5` — `CONTENT`
|
||||
|
||||
Назначение:
|
||||
|
||||
- хранение новых смысловых материалов канала;
|
||||
- сохранение линии канала;
|
||||
- поддержка edit-версий и логического удаления.
|
||||
|
||||
### 3.1.1. Подтипы `CONTENT`
|
||||
|
||||
- `subType=10` — `CONTENT_PLAIN`
|
||||
- `subType=11` — `CONTENT_EDIT_PLAIN`
|
||||
- `subType=20` — `CONTENT_EXERCISE`
|
||||
- `subType=21` — `CONTENT_EDIT_EXERCISE`
|
||||
- `subType=30` — `CONTENT_SERVICE`
|
||||
- `subType=31` — `CONTENT_EDIT_SERVICE`
|
||||
- `subType=40` — `CONTENT_COURSE`
|
||||
- `subType=41` — `CONTENT_EDIT_COURSE`
|
||||
- `subType=50` — `CONTENT_ENTRYPOINT`
|
||||
- `subType=51` — `CONTENT_EDIT_ENTRYPOINT`
|
||||
|
||||
### 3.1.2. Семантика подтипов
|
||||
|
||||
- `CONTENT_PLAIN` — обычный текст нового поколения.
|
||||
- `CONTENT_EXERCISE` — упражнение, которое можно выполнять многократно.
|
||||
- `CONTENT_SERVICE` — услуга / процедура, которую можно проходить многократно.
|
||||
- `CONTENT_COURSE` — курс / оглавление.
|
||||
- `CONTENT_ENTRYPOINT` — стартовое сообщение канала.
|
||||
|
||||
### 3.1.3. Почему `entrypoint` отдельный тип
|
||||
|
||||
`entrypoint` не считается курсом.
|
||||
|
||||
Это отдельная сущность, потому что:
|
||||
|
||||
- она описывает вход в канал;
|
||||
- по ней нельзя делать `started / completed / abandoned`;
|
||||
- у канала в каждый момент времени должна быть только одна актуальная стартовая страница.
|
||||
|
||||
### 3.1.4. Ограничение на `entrypoint`
|
||||
|
||||
Для одного канала допускается только один исходный блок `CONTENT_ENTRYPOINT`.
|
||||
|
||||
Правила:
|
||||
|
||||
- если entrypoint уже существует, создать второй нельзя;
|
||||
- изменять можно только через `CONTENT_EDIT_ENTRYPOINT`;
|
||||
- если entrypoint логически удалён, UI должен считать, что стартовой страницы больше нет;
|
||||
- исторический блок при этом остаётся в цепочке.
|
||||
|
||||
### 3.1.5. Формат body для `CONTENT_*`
|
||||
|
||||
Для `version=1` рекомендуется использовать формат, максимально совместимый по логике с текущими `TEXT_POST` / `TEXT_EDIT_POST`.
|
||||
|
||||
#### Создающие блоки
|
||||
|
||||
Для:
|
||||
|
||||
- `CONTENT_PLAIN`
|
||||
- `CONTENT_EXERCISE`
|
||||
- `CONTENT_SERVICE`
|
||||
- `CONTENT_COURSE`
|
||||
- `CONTENT_ENTRYPOINT`
|
||||
|
||||
body:
|
||||
|
||||
```text
|
||||
ContentLineBody_v1
|
||||
- lineCode: int32
|
||||
- prevLineNumber: int32
|
||||
- prevLineHash32: [32]
|
||||
- thisLineNumber: int32
|
||||
- textLenBytes: uint16
|
||||
- text UTF-8
|
||||
```
|
||||
|
||||
#### Edit-блоки
|
||||
|
||||
Для:
|
||||
|
||||
- `CONTENT_EDIT_PLAIN`
|
||||
- `CONTENT_EDIT_EXERCISE`
|
||||
- `CONTENT_EDIT_SERVICE`
|
||||
- `CONTENT_EDIT_COURSE`
|
||||
- `CONTENT_EDIT_ENTRYPOINT`
|
||||
|
||||
body:
|
||||
|
||||
```text
|
||||
ContentEditBody_v1
|
||||
- lineCode: int32
|
||||
- prevLineNumber: int32
|
||||
- prevLineHash32: [32]
|
||||
- thisLineNumber: int32
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- textLenBytes: uint16
|
||||
- text UTF-8
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- edit всегда ссылается на оригинальный блок соответствующего типа;
|
||||
- `toBlockchainName` в edit не хранится;
|
||||
- `textLen=0` означает логическое удаление содержимого;
|
||||
- тип исходной сущности edit не меняет.
|
||||
|
||||
### 3.1.6. Что считается комментарием
|
||||
|
||||
Комментарии не требуют нового формата.
|
||||
|
||||
Для обсуждения новых контентных сущностей продолжают использоваться уже существующие:
|
||||
|
||||
- `TEXT_REPLY`
|
||||
- `TEXT_EDIT_REPLY`
|
||||
|
||||
Это позволяет не ломать старую reply-механику и reuse текущую модель тредов.
|
||||
|
||||
## 4. Текстовые отзывы
|
||||
|
||||
## 4.1. Новый `type=6` — `TEXT_RATING`
|
||||
|
||||
Назначение:
|
||||
|
||||
- текстовая оценка / отзыв на объект;
|
||||
- без числовой шкалы;
|
||||
- с возможностью редактирования и логического удаления.
|
||||
|
||||
Смысл `TEXT_RATING`:
|
||||
|
||||
- это текст;
|
||||
- это специальный отзыв / мнение / оценка;
|
||||
- это явный сигнал, что перед нами не просто комментарий, а осмысленный отзыв;
|
||||
- в будущем это поле можно отдельно анализировать нейронками.
|
||||
|
||||
### 4.1.1. Подтипы
|
||||
|
||||
- `subType=10` — `TEXT_RATING_POST`
|
||||
- `subType=11` — `TEXT_RATING_EDIT`
|
||||
|
||||
### 4.1.2. Где разрешён `TEXT_RATING_POST`
|
||||
|
||||
Разрешён на target:
|
||||
|
||||
- `HEADER` пользователя;
|
||||
- контентный блок `type=5`;
|
||||
- при необходимости в будущем — на другие target-блоки по отдельному решению.
|
||||
|
||||
Сейчас в данном ТЗ:
|
||||
|
||||
- отзыв / оценка на пользователя — да;
|
||||
- отзыв / оценка на контент — да;
|
||||
- отзыв / лайк на канал целиком — не вводится, только оставляется как будущая возможность.
|
||||
|
||||
### 4.1.3. Где и как используется `TEXT_RATING_POST`
|
||||
|
||||
`TEXT_RATING_POST` можно создавать:
|
||||
|
||||
- как отзыв на контентный блок;
|
||||
- как отзыв на пользователя через target на `HEADER`;
|
||||
- как специальный ответ вместо обычного комментария.
|
||||
|
||||
Практическое правило для UI:
|
||||
|
||||
- при ответе на любое сообщение пользователь может выбрать:
|
||||
- обычный ответ;
|
||||
- `мнение / отзыв`.
|
||||
|
||||
### 4.1.4. Формат body
|
||||
|
||||
#### Создание
|
||||
|
||||
```text
|
||||
TextRatingBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- textLenBytes: uint16
|
||||
- text UTF-8
|
||||
```
|
||||
|
||||
#### Редактирование
|
||||
|
||||
```text
|
||||
TextRatingEditBody_v1
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- textLenBytes: uint16
|
||||
- text UTF-8
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- edit ссылается на оригинальный `TEXT_RATING_POST`;
|
||||
- пустой текст в edit означает логическое удаление отзыва.
|
||||
|
||||
## 5. Статусные действия и накопительные события
|
||||
|
||||
## 5.1. Новый `type=7` — `STATUS_ACTION`
|
||||
|
||||
Назначение:
|
||||
|
||||
- хранение действий пользователя по отношению к контенту;
|
||||
- вычисление текущего статуса;
|
||||
- накопительный учёт повторных прохождений;
|
||||
- подтверждение статусов другими людьми.
|
||||
|
||||
### 5.1.1. Подтипы
|
||||
|
||||
- `subType=10` — `STATUS_DONE_ONCE`
|
||||
- `subType=20` — `STATUS_INTERESTED`
|
||||
- `subType=30` — `STATUS_STARTED`
|
||||
- `subType=40` — `STATUS_COMPLETED`
|
||||
- `subType=50` — `STATUS_ABANDONED`
|
||||
- `subType=60` — `STATUS_CONFIRMED`
|
||||
|
||||
### 5.1.2. Матрица допустимости по контенту
|
||||
|
||||
`STATUS_DONE_ONCE` разрешён только для:
|
||||
|
||||
- `CONTENT_EXERCISE`
|
||||
- `CONTENT_SERVICE`
|
||||
|
||||
`STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED` разрешены только для:
|
||||
|
||||
- `CONTENT_EXERCISE`
|
||||
- `CONTENT_COURSE`
|
||||
|
||||
`CONTENT_ENTRYPOINT` не поддерживает:
|
||||
|
||||
- `interested`
|
||||
- `started`
|
||||
- `completed`
|
||||
- `abandoned`
|
||||
|
||||
### 5.1.3. Как считать текущее состояние
|
||||
|
||||
Для пары:
|
||||
|
||||
- `actorLogin`
|
||||
- `targetBlock`
|
||||
|
||||
актуальным статусом считается последнее по времени статусное событие из набора:
|
||||
|
||||
- `STATUS_INTERESTED`
|
||||
- `STATUS_STARTED`
|
||||
- `STATUS_COMPLETED`
|
||||
- `STATUS_ABANDONED`
|
||||
|
||||
Следствия:
|
||||
|
||||
- у одного пользователя по одному объекту в каждый момент времени только один актуальный статус;
|
||||
- если последним пришёл `interested`, статус считается “заинтересовался / рассматривает, но ещё не начал”;
|
||||
- если последним пришёл `started`, статус считается “в процессе”;
|
||||
- если последним пришёл `completed`, статус считается “завершён / освоен / знаю”;
|
||||
- если последним пришёл `abandoned`, статус считается “брошен”.
|
||||
|
||||
### 5.1.4. Как считать количество прохождений
|
||||
|
||||
`STATUS_DONE_ONCE` не меняет текущий статус.
|
||||
|
||||
Он считается отдельно как накопительное событие.
|
||||
|
||||
Сервер должен уметь считать:
|
||||
|
||||
- сколько раз пользователь сделал упражнение;
|
||||
- сколько раз пользователь прошёл услугу / процедуру.
|
||||
|
||||
### 5.1.5. Дополнительный текст действия
|
||||
|
||||
Каждое действие `STATUS_*` может содержать дополнительный текст-комментарий.
|
||||
|
||||
Примеры:
|
||||
|
||||
- как именно делал упражнение;
|
||||
- чем заинтересовал курс;
|
||||
- с какими мыслями начал курс;
|
||||
- почему бросил;
|
||||
- что именно подтверждает подтверждающий человек.
|
||||
|
||||
### 5.1.6. Подтверждение статуса
|
||||
|
||||
`STATUS_CONFIRMED` разрешён только на target-статусы:
|
||||
|
||||
- `STATUS_DONE_ONCE`
|
||||
- `STATUS_INTERESTED`
|
||||
- `STATUS_STARTED`
|
||||
- `STATUS_COMPLETED`
|
||||
- `STATUS_ABANDONED`
|
||||
|
||||
Это значит:
|
||||
|
||||
- подтверждение не ставится прямо на курс или упражнение;
|
||||
- подтверждение ставится на конкретный статусный блок другого человека.
|
||||
|
||||
Подтверждение:
|
||||
|
||||
- не меняет основной статус автора;
|
||||
- не меняет счётчик `done_once`;
|
||||
- хранится как отдельное мнение / свидетельство.
|
||||
|
||||
### 5.1.7. Формат body
|
||||
|
||||
Для `STATUS_DONE_ONCE`, `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED`:
|
||||
|
||||
```text
|
||||
StatusActionBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- noteLenBytes: uint16
|
||||
- note UTF-8
|
||||
```
|
||||
|
||||
Для `STATUS_CONFIRMED`:
|
||||
|
||||
```text
|
||||
StatusConfirmBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- noteLenBytes: uint16
|
||||
- note UTF-8
|
||||
```
|
||||
|
||||
На уровне бинарного формата тело можно оставить одинаковым.
|
||||
Различие задаётся `subType` и правилами валидации target.
|
||||
|
||||
## 6. Community-события
|
||||
|
||||
## 6.1. Новый `type=8` — `COMMUNITY_EVENT`
|
||||
|
||||
Назначение:
|
||||
|
||||
- заявки в сообщество;
|
||||
- выход из сообщества;
|
||||
- принятие;
|
||||
- исключение.
|
||||
|
||||
### 6.1.1. Подтипы
|
||||
|
||||
- `subType=10` — `COMMUNITY_JOIN_REQUEST`
|
||||
- `subType=20` — `COMMUNITY_LEAVE`
|
||||
- `subType=30` — `COMMUNITY_ACCEPT`
|
||||
- `subType=40` — `COMMUNITY_REMOVE`
|
||||
|
||||
### 6.1.2. Базовая логика
|
||||
|
||||
`COMMUNITY_JOIN_REQUEST`
|
||||
|
||||
- создаёт пользователь;
|
||||
- target — `CONTENT_ENTRYPOINT` канала;
|
||||
- может содержать текст заявки.
|
||||
|
||||
`COMMUNITY_LEAVE`
|
||||
|
||||
- создаёт сам участник;
|
||||
- target — `CONTENT_ENTRYPOINT` канала;
|
||||
- подтверждение не требуется;
|
||||
- может содержать текст.
|
||||
|
||||
`COMMUNITY_ACCEPT`
|
||||
|
||||
- создаёт владелец канала;
|
||||
- target — конкретный блок `COMMUNITY_JOIN_REQUEST`;
|
||||
- может содержать текст.
|
||||
|
||||
`COMMUNITY_REMOVE`
|
||||
|
||||
- создаёт владелец канала;
|
||||
- target — `CONTENT_ENTRYPOINT` канала;
|
||||
- body дополнительно хранит `subjectLogin`, кого исключили;
|
||||
- может содержать текст.
|
||||
|
||||
### 6.1.3. Текущее членство
|
||||
|
||||
Пользователь считается текущим участником сообщества, если:
|
||||
|
||||
- у него есть хотя бы одно принятие в это сообщество;
|
||||
- после этого принятия нет более позднего:
|
||||
- `COMMUNITY_LEAVE`
|
||||
- `COMMUNITY_REMOVE`
|
||||
|
||||
Заявка сама по себе членство не создаёт.
|
||||
|
||||
### 6.1.4. Формат body
|
||||
|
||||
Для `JOIN_REQUEST` и `LEAVE`:
|
||||
|
||||
```text
|
||||
CommunityActionBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- noteLenBytes: uint16
|
||||
- note UTF-8
|
||||
```
|
||||
|
||||
Для `ACCEPT`:
|
||||
|
||||
```text
|
||||
CommunityAcceptBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- noteLenBytes: uint16
|
||||
- note UTF-8
|
||||
```
|
||||
|
||||
Для `REMOVE`:
|
||||
|
||||
```text
|
||||
CommunityRemoveBody_v1
|
||||
- toBlockchainNameLen: uint8
|
||||
- toBlockchainName UTF-8
|
||||
- toBlockGlobalNumber: int32
|
||||
- toBlockHash32: [32]
|
||||
- subjectLoginLen: uint8
|
||||
- subjectLogin ASCII
|
||||
- noteLenBytes: uint16
|
||||
- note UTF-8
|
||||
```
|
||||
|
||||
## 7. Что остаётся на старых типах
|
||||
|
||||
### 7.0. Обычный канал и лента достижений
|
||||
|
||||
На уровне продукта рекомендуется различать:
|
||||
|
||||
- обычный канал постов пользователя;
|
||||
- отдельную ленту его действий и достижений.
|
||||
|
||||
В обычном канале пользователь:
|
||||
|
||||
- пишет посты;
|
||||
- публикует материалы;
|
||||
- общается и обсуждает.
|
||||
|
||||
В ленте достижений видны события:
|
||||
|
||||
- какие упражнения он делал;
|
||||
- какие услуги / процедуры проходил;
|
||||
- какие курсы его заинтересовали;
|
||||
- какие курсы он начал;
|
||||
- какие курсы он завершил;
|
||||
- что он бросил.
|
||||
|
||||
В данном ТЗ эта модель фиксируется как продуктовая логика.
|
||||
Конкретный способ хранения можно реализовать:
|
||||
|
||||
- либо отдельным специальным каналом;
|
||||
- либо отдельным режимом чтения по статусным блокам.
|
||||
|
||||
### 7.1. Лайк пользователю
|
||||
|
||||
Лайк пользователю не вводится как `REACTION`.
|
||||
|
||||
Он остаётся в слое социальных связей:
|
||||
|
||||
- через `CONNECTION`
|
||||
- как будущий отдельный подтип связи
|
||||
|
||||
В этом ТЗ сам новый подтип связи не описывается детально.
|
||||
Нужно только зафиксировать правило:
|
||||
|
||||
- лайк человека относится к графу связей, а не к реакции на блок.
|
||||
|
||||
### 7.2. Лайк контента
|
||||
|
||||
Лайк на:
|
||||
|
||||
- `CONTENT_PLAIN`
|
||||
- `CONTENT_EXERCISE`
|
||||
- `CONTENT_SERVICE`
|
||||
- `CONTENT_COURSE`
|
||||
- `CONTENT_ENTRYPOINT`
|
||||
|
||||
может использовать уже существующий:
|
||||
|
||||
- `REACTION_LIKE`
|
||||
- `REACTION_UNLIKE`
|
||||
|
||||
Отдельный новый формат для лайка контента не нужен.
|
||||
|
||||
### 7.3. Канал целиком
|
||||
|
||||
В текущем ТЗ не вводятся:
|
||||
|
||||
- отзыв на канал целиком;
|
||||
- лайк канала целиком.
|
||||
|
||||
Это оставляется как будущая возможность.
|
||||
|
||||
### 7.4. Отзывы о людях
|
||||
|
||||
Отзывы о человеке как о человеке в текущем ТЗ допустимы через `TEXT_RATING` на `HEADER`.
|
||||
|
||||
Но продуктовую модель их показа нужно отдельно продумать.
|
||||
|
||||
Направление для будущего:
|
||||
|
||||
- просмотр отзывов о человеке на вкладке связей;
|
||||
- приоритетный вывод отзывов от близких друзей, родственников, друзей и контактов;
|
||||
- затем вывод остальных отзывов.
|
||||
|
||||
Эта тема полезна, но требует дополнительной осторожной проработки с точки зрения UX и социальных рисков.
|
||||
|
||||
## 8. Требования к серверу
|
||||
|
||||
Сервер после внедрения должен уметь:
|
||||
|
||||
1. Валидировать новые `type=5..8`.
|
||||
2. Хранить новые блоки без ломки старого чтения.
|
||||
3. Определять текущий статус пользователя по объекту:
|
||||
- `interested`
|
||||
- `started`
|
||||
- `completed`
|
||||
- `abandoned`
|
||||
4. Считать накопительные события `done_once` для:
|
||||
- `exercise`
|
||||
- `service`
|
||||
5. Считать подтверждения статусов.
|
||||
6. Определять единственный актуальный `entrypoint` канала.
|
||||
7. Определять текущее членство в сообществе канала.
|
||||
8. Поддерживать внутренние ссылки вида:
|
||||
- `SHiNE/<blockchainName>/<blockNumber>`
|
||||
- `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
|
||||
|
||||
## 9. Требования к UI
|
||||
|
||||
UI после внедрения должен уметь:
|
||||
|
||||
1. Показывать разные карточки для:
|
||||
- текста
|
||||
- упражнения
|
||||
- услуги
|
||||
- курса
|
||||
- entrypoint
|
||||
2. Показывать стартовую страницу канала, если `entrypoint` существует.
|
||||
3. Не показывать entrypoint, если он логически удалён.
|
||||
4. Давать человеку только допустимые действия по типу материала.
|
||||
5. Показывать:
|
||||
- текущий статус;
|
||||
- количество `done_once`;
|
||||
- подтверждения статуса.
|
||||
6. Показывать отдельные действия-кнопки на специальных блоках, например:
|
||||
- `Выполнил упражнение`
|
||||
- `Прошёл процедуру`
|
||||
- `Заинтересовало`
|
||||
- `Начал курс`
|
||||
- `Закончил курс`
|
||||
7. При ответе на сообщение давать выбор:
|
||||
- обычный ответ;
|
||||
- `мнение / отзыв`.
|
||||
8. Открывать внутренние ссылки SHiNE.
|
||||
|
||||
## 10. Вывод по совместимости
|
||||
|
||||
Предлагаемая модель реализуема без слома старого блокчейна.
|
||||
|
||||
Причина:
|
||||
|
||||
- старые `type=0..4` не меняются;
|
||||
- новые сущности вводятся только как новые `type=5..8`;
|
||||
- существующие `reply`, `like`, `edit`, `HEADER`, `CREATE_CHANNEL` и `CONNECTION` продолжают работать как раньше;
|
||||
- старые клиенты смогут игнорировать новые типы как неизвестные;
|
||||
- новые клиенты смогут постепенно включать поддержку нового функционала.
|
||||
|
||||
Итог:
|
||||
|
||||
- это расширение формата блокчейна;
|
||||
- это не миграция со сломом старых блоков;
|
||||
- это можно внедрять поэтапно.
|
||||
@@ -0,0 +1,2 @@
|
||||
Доделть мелочи
|
||||
и разместить проект нормально на гитхаб
|
||||
@@ -0,0 +1,7 @@
|
||||
# Межсерверные звонки
|
||||
|
||||
Доделать Звонки что бы работало как сигнал о том что вызов идёт.
|
||||
|
||||
и
|
||||
Пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.5.8
|
||||
server.version=1.4.7
|
||||
client.version=1.7.0
|
||||
server.version=1.6.0
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
- `user_access_servers_current`
|
||||
- `solana_user_pda_history`
|
||||
- источник истины по пользовательским 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;
|
||||
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
||||
|
||||
@@ -56,7 +59,7 @@ psql \
|
||||
Скрипт:
|
||||
|
||||
- создаёт таблицу версии схемы `db_schema_version`;
|
||||
- ставит `schema_version = 2`;
|
||||
- ставит актуальный `schema_version`;
|
||||
- создаёт таблицы sync-модуля Solana users;
|
||||
- создаёт server runtime tables;
|
||||
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
|
||||
- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок
|
||||
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
|
||||
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||
- `internal_error`
|
||||
|
||||
@@ -144,13 +145,14 @@
|
||||
|
||||
6. **STATUS_ACTION (type=5)**
|
||||
- `STATUS_DONE_ONCE (10)`
|
||||
- `STATUS_INTERESTED (20)`
|
||||
- `STATUS_STARTED (30)`
|
||||
- `STATUS_IN_STUDY (40)`
|
||||
- `STATUS_COMPLETED (50)`
|
||||
- `STATUS_ABANDONED (60)`
|
||||
- `STATUS_LEARNED (70)`
|
||||
- `STATUS_CONFIRMED (80)`
|
||||
- `STATUS_LEARNED (20)`
|
||||
- `STATUS_SERVICE_PASSED (30)`
|
||||
- `STATUS_CONFIRMED (100)`
|
||||
- `STATUS_INTERESTED (110)`
|
||||
- `STATUS_STARTED (120)`
|
||||
- `STATUS_IN_STUDY (130)`
|
||||
- `STATUS_ABANDONED (140)`
|
||||
- `STATUS_COMPLETED (150)`
|
||||
|
||||
## 6. Практические payload-форматы для каналов и вложений
|
||||
|
||||
@@ -158,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:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
Пример вложения с отдельным preview-файлом:
|
||||
|
||||
```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`.
|
||||
@@ -181,8 +183,8 @@
|
||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Человекочитаемое имя канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
<S:title;v=1;Человекочитаемое имя канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Описание канала
|
||||
```
|
||||
|
||||
@@ -195,12 +197,12 @@
|
||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Новое имя канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
<S:title;v=1;Новое имя канала>
|
||||
<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. Хватает ли функций сейчас
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||
предки, фокус-сообщение, потомки.
|
||||
|
||||
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||
|
||||
5. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
|
||||
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
|
||||
@@ -192,6 +194,7 @@
|
||||
"text": "текущая версия",
|
||||
"likesCount": 12,
|
||||
"repliesCount": 3,
|
||||
"ratingsCount": 2,
|
||||
"versionsTotal": 4,
|
||||
"versions": [
|
||||
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
||||
@@ -248,6 +251,36 @@
|
||||
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
|
||||
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
|
||||
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
|
||||
- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит:
|
||||
- `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`;
|
||||
- `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`.
|
||||
- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений:
|
||||
- `TEXT_REPLY`;
|
||||
- `TEXT_RATING`.
|
||||
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
|
||||
|
||||
---
|
||||
|
||||
## 4) GetPersonalDiary
|
||||
|
||||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||
|
||||
- Вызов доступен только владельцу дневника.
|
||||
- Сообщения в ответе строятся из блоков `STATUS_ACTION`.
|
||||
- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "GetPersonalDiary",
|
||||
"requestId": "req-4",
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"limit": 200,
|
||||
"sort": "asc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
||||
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
|
||||
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||
- `type=5` — STATUS_ACTION: DONE_ONCE/INTERESTED/STARTED/IN_STUDY/COMPLETED/ABANDONED/LEARNED/CONFIRMED.
|
||||
- `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED.
|
||||
|
||||
## Примечание
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ Payload включает:
|
||||
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Название канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
<S:title;v=1;Название канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
Описание канала.
|
||||
```
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
7. `subType=90` — `TEXT_CHANNEL_META`
|
||||
- скрытый технический снимок профиля канала;
|
||||
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
||||
- содержит line-поля + текст с тегами `S:title`/`S:ava` и описанием;
|
||||
- не отображается как обычное сообщение ленты;
|
||||
- применяется сервером к текущему состоянию канала.
|
||||
|
||||
@@ -73,7 +73,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||
|
||||
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
|
||||
## Вложения в текстовых сообщениях (`S:att v=1`)
|
||||
|
||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
||||
|
||||
@@ -84,31 +84,31 @@ 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
|
||||
<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`
|
||||
- `name` - имя файла, закодированное через `encodeURIComponent`
|
||||
- `size` - размер файла в байтах
|
||||
- `v=1`
|
||||
- `nm` - имя файла, закодированное через `encodeURIComponent`
|
||||
- `sz` - размер файла в байтах
|
||||
- `sha256` - SHA-256 исходного файла в hex
|
||||
- `ar` - короткий Arweave Transaction ID, без полного URL
|
||||
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
|
||||
- при наличии превью дополнительно могут присутствовать `preAr` и `preSha256`
|
||||
|
||||
Правила клиента:
|
||||
|
||||
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
|
||||
- если сообщение начинается с одного или нескольких валидных attach-блоков (`<SHiNE:attach;...>`, `<S:attach;...>` или `<S:att;...>`), новый клиент скрывает эти блоки и показывает карточки вложений;
|
||||
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
||||
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
||||
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
||||
|
||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
|
||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `preAr/preSha256`.
|
||||
|
||||
@@ -5,29 +5,32 @@
|
||||
## Подтипы
|
||||
|
||||
1. `subType=10` — `STATUS_DONE_ONCE`
|
||||
- выполнил / прошёл один раз.
|
||||
- выполнил упражнение один раз.
|
||||
|
||||
2. `subType=20` — `STATUS_INTERESTED`
|
||||
- заинтересовался материалом.
|
||||
2. `subType=20` — `STATUS_LEARNED`
|
||||
- изучил упражнение / комплекс.
|
||||
|
||||
3. `subType=30` — `STATUS_STARTED`
|
||||
- начал.
|
||||
3. `subType=30` — `STATUS_SERVICE_PASSED`
|
||||
- прошёл услугу / процедуру.
|
||||
|
||||
4. `subType=40` — `STATUS_IN_STUDY`
|
||||
- находится в процессе полноценного изучения.
|
||||
|
||||
5. `subType=50` — `STATUS_COMPLETED`
|
||||
- завершил.
|
||||
|
||||
6. `subType=60` — `STATUS_ABANDONED`
|
||||
- бросил.
|
||||
|
||||
7. `subType=70` — `STATUS_LEARNED`
|
||||
- выучил упражнение / комплекс.
|
||||
|
||||
8. `subType=80` — `STATUS_CONFIRMED`
|
||||
4. `subType=100` — `STATUS_CONFIRMED`
|
||||
- подтвердил чужой status-блок.
|
||||
|
||||
5. `subType=110` — `STATUS_INTERESTED`
|
||||
- заинтересовался курсом.
|
||||
|
||||
6. `subType=120` — `STATUS_STARTED`
|
||||
- начал курс.
|
||||
|
||||
7. `subType=130` — `STATUS_IN_STUDY`
|
||||
- находится в процессе полноценного изучения курса.
|
||||
|
||||
8. `subType=140` — `STATUS_ABANDONED`
|
||||
- бросил курс.
|
||||
|
||||
9. `subType=150` — `STATUS_COMPLETED`
|
||||
- завершил курс.
|
||||
|
||||
## Формат body
|
||||
|
||||
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
||||
@@ -55,6 +58,10 @@
|
||||
- Текст может быть пустым: основной смысл задаётся самим `subType`.
|
||||
- Базовые статусы пользователь ставит сам за себя.
|
||||
- `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок.
|
||||
- Допустимые target-типы:
|
||||
- `STATUS_DONE_ONCE` и `STATUS_LEARNED` только для `TEXT_EXERCISE`;
|
||||
- `STATUS_SERVICE_PASSED` только для `TEXT_SERVICE`;
|
||||
- `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_IN_STUDY`, `STATUS_ABANDONED`, `STATUS_COMPLETED` только для `TEXT_COURSE`.
|
||||
|
||||
## Что не поддерживается
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
|
||||
# Вложения в TEXT-сообщениях (`S:att v=1`)
|
||||
|
||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
|
||||
## Общий вид
|
||||
|
||||
Один attach-блок:
|
||||
Канонический attach-блок:
|
||||
|
||||
```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
|
||||
<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
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
@@ -41,18 +41,18 @@
|
||||
|
||||
## Поля
|
||||
|
||||
Обязательные поля:
|
||||
Обязательные поля канонического формата:
|
||||
|
||||
- `v=1` - версия формата attach-блока;
|
||||
- `name` - имя файла, закодированное через `encodeURIComponent`;
|
||||
- `size` - размер файла в байтах;
|
||||
- `nm` - имя файла, закодированное через `encodeURIComponent`;
|
||||
- `sz` - размер файла в байтах;
|
||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||
|
||||
Дополнительные поля для `v=2`:
|
||||
Дополнительные поля для превью:
|
||||
|
||||
- `previewAr` - короткий Arweave Transaction ID файла-превью;
|
||||
- `previewSha256` - SHA-256 файла-превью в hex.
|
||||
- `preAr` - короткий Arweave Transaction ID файла-превью;
|
||||
- `preSha256` - SHA-256 файла-превью в hex.
|
||||
|
||||
## Хранение файла
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
- SHA-256;
|
||||
- Arweave `txId`.
|
||||
|
||||
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
|
||||
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `preAr/preSha256`.
|
||||
|
||||
## Создание вложения в UI
|
||||
|
||||
@@ -78,7 +78,7 @@ UI поддерживает три сценария:
|
||||
- основной видеофайл;
|
||||
- отдельное изображение-превью.
|
||||
|
||||
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `preAr/preSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||
|
||||
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
||||
|
||||
@@ -99,7 +99,7 @@ UI поддерживает три сценария:
|
||||
- изображение показывает как ограниченное по размеру превью;
|
||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||
6. если у видео есть `preAr/preSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||
7. при перелистывании останавливает воспроизводящееся видео;
|
||||
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||
|
||||
@@ -118,7 +118,9 @@ UI поддерживает три сценария:
|
||||
|
||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
|
||||
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
|
||||
- старые блоки `<SHiNE:attach ...>`, промежуточные `<S:attach ...>` и новые `<S:att ...>` читаются одинаково;
|
||||
- старые поля `name/size/previewAr/previewSha256` и новые `nm/sz/preAr/preSha256` читаются одинаково;
|
||||
- старые блоки `v=2` продолжают читаться как legacy-форма вложения с превью;
|
||||
- `preAr/preSha256` используются только если присутствуют оба поля и оба валидны;
|
||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Название канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
<S:title;v=1;Название канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
Описание канала.
|
||||
```
|
||||
|
||||
Поддерживаемые теги:
|
||||
|
||||
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
|
||||
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||
- `<S:title;v=1;...>` — человекочитаемое имя канала.
|
||||
- `<S:ava;v=1;sz=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||
|
||||
## Правила
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
- Длина описания — максимум 250 Unicode code points.
|
||||
- `avatar.ar` — Arweave transaction id из 43 символов.
|
||||
- `avatar.sha256` — 64 hex-символа.
|
||||
- `avatar.size` — положительный размер файла в байтах.
|
||||
- `avatar.sz` — положительный размер файла в байтах.
|
||||
|
||||
Если meta-блок невалиден, сервер не применяет его целиком.
|
||||
|
||||
@@ -59,3 +59,9 @@
|
||||
## Замена старых команд
|
||||
|
||||
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
||||
|
||||
## Совместимость
|
||||
|
||||
- Новый UI пишет сокращённые теги `<S:title ...>` и `<S:ava ...>`.
|
||||
- Серверное чтение поддерживает и старые теги `<SHiNE:title ...>` / `<SHiNE:avatar ...>`.
|
||||
- Для размера аватара чтение поддерживает и старое поле `size`, и новое поле `sz`.
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 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
|
||||
- Базовый коммит-ориентир: `ee185cf`.
|
||||
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
|
||||
- `10` — `STATUS_DONE_ONCE`;
|
||||
- `20` — `STATUS_LEARNED`;
|
||||
- `30` — `STATUS_SERVICE_PASSED`;
|
||||
- `100` — `STATUS_CONFIRMED`;
|
||||
- `110/120/130/140/150` — курсные статусы `INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED`.
|
||||
- Зафиксированы допустимые связи `STATUS_ACTION -> target`:
|
||||
- упражнение: `DONE_ONCE`, `LEARNED`;
|
||||
- услуга: `SERVICE_PASSED`;
|
||||
- курс: `INTERESTED`, `STARTED`, `IN_STUDY`, `ABANDONED`, `COMPLETED`.
|
||||
- Добавлен серверный read API `GetPersonalDiary` для виртуальной ленты личного дневника из `STATUS_ACTION`.
|
||||
|
||||
## 2026-08-09 19:40:00 +0400
|
||||
- Базовый коммит-ориентир: `3552e05`.
|
||||
- Уточнено серверное чтение каналов и тредов для `TEXT_RATING`:
|
||||
- `GetChannelMessages` и `GetMessageThread` теперь отдают отдельное поле `ratingsCount`;
|
||||
- `GetMessageThread` включает `TEXT_RATING` в общее дерево потомков вместе с `TEXT_REPLY`;
|
||||
- в `docs/API/06_Channels_Read_API.md` зафиксировано, что потомки треда возвращаются вперемешку по времени создания.
|
||||
|
||||
## 2026-08-09 18:55:16 +0400
|
||||
- Базовый коммит-ориентир: `43f54c9`.
|
||||
- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов:
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
||||
Статусные действия пользователя (`msg_type=5`).
|
||||
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
|
||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
|
||||
Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений.
|
||||
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Figma
|
||||
|
||||
Эта папка хранит рабочие инструкции по переносу экранов SHiNE в Figma и по обратному переносу изменений из Figma в код.
|
||||
|
||||
## Что здесь лежит
|
||||
|
||||
- `README.md` — точка входа и краткий регламент.
|
||||
- `TRANSFER_UI_SCREENS.md` — подробная инструкция по переносу экранов UI в Figma и обратно.
|
||||
|
||||
## Когда читать
|
||||
|
||||
Читать перед любыми задачами вида:
|
||||
- перенести экран из `shine-UI` в Figma;
|
||||
- собрать новый Figma-файл для экранов SHiNE;
|
||||
- перенести изменения из Figma обратно в код;
|
||||
- уточнить, каким способом переносить экраны: по одному или пачкой.
|
||||
|
||||
## Ключевое правило
|
||||
|
||||
Для экранов SHiNE безопасный рабочий способ на текущий момент:
|
||||
- переносить экраны в Figma по одному;
|
||||
- не пытаться сразу переносить длинный auth-flow пачкой;
|
||||
- после каждого переноса визуально проверять результат в самой Figma;
|
||||
- только после удачного одного экрана переходить к следующему.
|
||||
|
||||
## Про Miro
|
||||
|
||||
Отдельной папки `Miro` пока нет.
|
||||
|
||||
Причина:
|
||||
- практики по Miro в проекте пока мало;
|
||||
- устойчивого процесса ещё нет;
|
||||
- как только появится стабильный сценарий работы с Miro, его нужно будет оформить аналогично Figma.
|
||||
@@ -1,222 +0,0 @@
|
||||
# Перенос экранов UI в Figma и обратно
|
||||
|
||||
## Зачем нужен этот документ
|
||||
|
||||
Этот документ фиксирует практический опыт, который уже был получен на переносе стартового экрана, экрана регистрации и дальнейших попытках.
|
||||
|
||||
Главная цель:
|
||||
- чтобы агент не повторял неудачные попытки;
|
||||
- чтобы переносы делались одинаково;
|
||||
- чтобы изменения из Figma можно было уверенно переносить назад в `shine-UI`.
|
||||
|
||||
## Где находится основной UI
|
||||
|
||||
- основной клиентский UI: `shine-UI/`
|
||||
- маршруты и список pre-auth экранов: `shine-UI/js/router.js`
|
||||
- экраны: `shine-UI/js/pages/`
|
||||
- общие стили: `shine-UI/styles/main.css`, `shine-UI/styles/layout.css`, `shine-UI/styles/components.css`
|
||||
|
||||
## Что считать успешным переносом в Figma
|
||||
|
||||
Успешный перенос экрана в Figma — это не просто фон и прямоугольники.
|
||||
|
||||
Нужно, чтобы:
|
||||
- были видны все ключевые текстовые элементы;
|
||||
- кнопки были перенесены как отдельные элементы;
|
||||
- поля ввода были явно видны;
|
||||
- экран был узнаваем визуально;
|
||||
- пользователь мог вручную подправить макет в Figma;
|
||||
- после правок можно было понять, что именно переносить обратно в код.
|
||||
|
||||
## Текущий рабочий способ
|
||||
|
||||
На текущем проекте лучший практический способ такой:
|
||||
|
||||
1. Переносить только один экран за раз.
|
||||
2. Сначала читать конкретный `js/pages/<screen>.js`.
|
||||
3. Затем читать связанные стили из `styles/components.css` и `styles/layout.css`.
|
||||
4. После этого вручную собирать экран в Figma как отдельный frame с явными элементами.
|
||||
5. Проверять в Figma, что не получился только фон без текста и контролов.
|
||||
6. Только после успешной проверки переходить к следующему экрану.
|
||||
|
||||
## Почему нельзя переносить пачкой
|
||||
|
||||
Был получен негативный опыт:
|
||||
- при переносе сразу многих экранов в Figma часть экранов отображалась как фон без нормальных надписей и элементов;
|
||||
- длинные экраны с большим количеством текста и форм разваливались;
|
||||
- автогенерация давала внешний вид, непригодный для ручной доработки.
|
||||
|
||||
Поэтому правило такое:
|
||||
- auth-flow, регистрация, вход, onboarding — переносить по одному экрану;
|
||||
- после каждого экрана ждать визуального подтверждения пользователя;
|
||||
- не объединять 5-10 экранов в один проход без отдельного разрешения и без промежуточной проверки.
|
||||
|
||||
## Рекомендуемый порядок переноса в Figma
|
||||
|
||||
### Вперёд: код -> Figma
|
||||
|
||||
1. Определить точный экран.
|
||||
2. Найти файл экрана в `shine-UI/js/pages/`.
|
||||
3. Найти используемые CSS-классы через поиск по файлу экрана.
|
||||
4. Вытащить:
|
||||
- тексты;
|
||||
- состав кнопок;
|
||||
- поля ввода;
|
||||
- карточки;
|
||||
- блоки статуса;
|
||||
- последовательность секций.
|
||||
5. Если экран длинный, всё равно переносить его как один frame, но собирать блоками сверху вниз.
|
||||
6. В Figma создавать отдельный экран рядом с уже существующими экранами, а не смешивать всё в одну кучу.
|
||||
7. После создания экрана проверить метаданные/скриншот Figma, если инструмент это позволяет.
|
||||
|
||||
### Назад: Figma -> код
|
||||
|
||||
1. Снять актуальный скриншот изменённого Figma-экрана.
|
||||
2. Получить метаданные узла, если это помогает понять структуру.
|
||||
3. Сравнить Figma с текущим кодом экрана.
|
||||
4. Переносить обратно в код только реальные изменения:
|
||||
- порядок блоков;
|
||||
- тексты;
|
||||
- размеры/отступы;
|
||||
- наличие или отсутствие карточек;
|
||||
- подписи кнопок;
|
||||
- видимость блоков.
|
||||
5. Не придумывать новые UX-решения без отдельного подтверждения пользователя, если их нет в Figma.
|
||||
6. После правок проверять экран локально или как минимум по коду и зависимостям.
|
||||
|
||||
## Что переносить вручную
|
||||
|
||||
Вручную, а не автогенерацией, нужно переносить:
|
||||
- экраны регистрации;
|
||||
- экраны входа;
|
||||
- длинные формы;
|
||||
- экраны с несколькими карточками;
|
||||
- экраны с длинными объясняющими текстами;
|
||||
- экраны, где важен порядок блоков.
|
||||
|
||||
Причина:
|
||||
- именно они чаще всего ломаются при слишком автоматическом переносе.
|
||||
|
||||
## Какие ошибки уже были
|
||||
|
||||
### Ошибка 1. Перенос пачкой
|
||||
|
||||
Проблема:
|
||||
- несколько экранов были добавлены сразу;
|
||||
- пользователь увидел, что на экранах в Figma «какая-то ерунда».
|
||||
|
||||
Вывод:
|
||||
- переносить по одному.
|
||||
|
||||
### Ошибка 2. Видно только фон
|
||||
|
||||
Проблема:
|
||||
- frame создавался, фон и свечения были видны;
|
||||
- тексты и элементы либо не появлялись, либо получались непригодными.
|
||||
|
||||
Вывод:
|
||||
- при сложных экранах собирать элементы вручную и явно.
|
||||
|
||||
### Ошибка 3. Слишком вольная реконструкция
|
||||
|
||||
Проблема:
|
||||
- экран формально был перенесён, но визуально не соответствовал ожиданию пользователя.
|
||||
|
||||
Вывод:
|
||||
- для SHiNE важнее узнаваемый и редактируемый экран, чем «формально похожий» экран.
|
||||
|
||||
## Обязательные проверки после переноса в Figma
|
||||
|
||||
После каждого нового экрана агент должен проверить:
|
||||
- виден ли заголовок;
|
||||
- видны ли кнопки;
|
||||
- видны ли поля ввода;
|
||||
- не исчезли ли длинные тексты;
|
||||
- не сломан ли порядок секций;
|
||||
- не оказался ли на холсте только фон и пустые прямоугольники.
|
||||
|
||||
Если хотя бы один пункт не выполнен:
|
||||
- не считать перенос завершённым;
|
||||
- либо переделать экран сразу;
|
||||
- либо остановиться и показать пользователю только после исправления.
|
||||
|
||||
## Правила для длинных экранов
|
||||
|
||||
Если экран длинный, например регистрация:
|
||||
- высота frame может быть больше стандартной мобильной высоты;
|
||||
- секции должны идти в правильном вертикальном порядке;
|
||||
- отдельные карточки должны быть вынесены в отдельные блоки;
|
||||
- тексты лучше упрощённо располагать вручную, чем терять их совсем.
|
||||
|
||||
## Правила для экрана регистрации
|
||||
|
||||
Экран `register-view` особенно чувствительный.
|
||||
|
||||
При переносе нужно отдельно учитывать:
|
||||
- заголовок и стрелку назад;
|
||||
- поля логина и пароля;
|
||||
- строку статуса длины пароля;
|
||||
- строку статуса проверки логина;
|
||||
- кнопку проверки логина;
|
||||
- отдельную карточку первого сервера;
|
||||
- отдельную карточку FAQ;
|
||||
- нижние кнопки `Назад` и `Далее`.
|
||||
|
||||
## Правила для экрана входа
|
||||
|
||||
Для экранов входа важно не смешивать:
|
||||
- экран выбора способа входа;
|
||||
- вход по логину/паролю;
|
||||
- вход через другое устройство;
|
||||
- вход по QR.
|
||||
|
||||
Каждый из них переносить отдельно.
|
||||
|
||||
## Что делать после правок пользователя в Figma
|
||||
|
||||
Если пользователь изменил экран в Figma:
|
||||
|
||||
1. Считать Figma источником визуальной правки.
|
||||
2. Сначала понять, что именно изменено:
|
||||
- тексты;
|
||||
- порядок блоков;
|
||||
- наличие блоков;
|
||||
- размеры;
|
||||
- отступы;
|
||||
- логика flow.
|
||||
3. Переносить эти изменения назад в код минимально необходимыми правками.
|
||||
4. Если из Figma следует уже не только визуальная, но и UX-логическая правка, отдельно проверить, что она согласована пользователем.
|
||||
|
||||
## Когда нужно отдельно согласовать ручную проверку
|
||||
|
||||
Если после изменения по Figma:
|
||||
- поменялась логика flow;
|
||||
- поменялась регистрация/вход;
|
||||
- нужен реальный прогон на test2;
|
||||
- затронута интеграция с Solana;
|
||||
|
||||
тогда нужно отдельно согласовать ручную проверку с пользователем.
|
||||
|
||||
## Что пока не оформлено для Miro
|
||||
|
||||
По Miro пока нет устойчивого процесса.
|
||||
|
||||
Из того, что уже понятно:
|
||||
- пока не стоит обещать такой же отлаженный перенос, как для Figma;
|
||||
- сначала нужно накопить хотя бы 2-3 реальных сценария работы;
|
||||
- только после этого оформлять отдельную папку и регламент.
|
||||
|
||||
## Краткая памятка для агента
|
||||
|
||||
Если задача звучит как:
|
||||
- «перенеси экран в Figma»;
|
||||
- «добавь экран в Figma»;
|
||||
- «я поправил экран в Figma, перенеси назад»;
|
||||
|
||||
то агент должен:
|
||||
|
||||
1. Прочитать этот документ.
|
||||
2. Работать по одному экрану.
|
||||
3. Не переносить auth-flow пачкой.
|
||||
4. Проверять результат после каждого экрана.
|
||||
5. При переносе обратно в код не гадать, а опираться на Figma-правки.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- `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:...>` вставок внутри plaintext DM после расшифровки
|
||||
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
||||
|
||||
Исторический устаревший документ сохранён отдельно:
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
||||
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
||||
|
||||
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
|
||||
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
|
||||
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
||||
|
||||
### 2.4. Источник истины по пользователю
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# Личные сообщения (DM) — v0.5 устаревшая спецификация
|
||||
|
||||
## Статус документа
|
||||
|
||||
Этот документ устарел и сохранён только как историческое описание ранее реализованной схемы DM.
|
||||
|
||||
Aктуальная целевая спецификация:
|
||||
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md`
|
||||
|
||||
Что в этом документе считать устаревшим:
|
||||
|
||||
- трактовку `encryptedBody` как фактически одинакового содержимого пары;
|
||||
- отсутствие нормального E2EE-шифрования DM;
|
||||
- старую модель удаления через пустую ревизию без терминального tombstone;
|
||||
- старую трактовку обновления только как общей пары без отдельной будущей модели перешифровки;
|
||||
- все упоминания legacy-формата read-receipt как части целевой архитектуры следующего этапа.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас в проекте реализованы:
|
||||
|
||||
- новый формат контентных личных сообщений `SHiNE_DM`;
|
||||
- ревизии сообщений через `revisionTimeMs`;
|
||||
- редактирование сообщения через повторную отправку той же логической пары;
|
||||
- удаление сообщения через пустую ревизию;
|
||||
- `upsert` последней версии сообщения на сервере.
|
||||
|
||||
Сейчас в проекте **не реализованы**:
|
||||
|
||||
- вложения в DM;
|
||||
- upload/download файлов для DM;
|
||||
- UI-кнопка прикрепления файла;
|
||||
- серверное хранение файловых связей для DM.
|
||||
|
||||
Черновик будущих вложений вынесен отдельно:
|
||||
|
||||
- `docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
|
||||
|
||||
## Общая схема
|
||||
|
||||
Личное сообщение по-прежнему отправляется парой signed-блоков:
|
||||
|
||||
- `type=1` — входящий блок для получателя;
|
||||
- `type=2` — исходящая копия для отправителя.
|
||||
|
||||
Read-receipt пока остаются в legacy-формате:
|
||||
|
||||
- `type=3` — входящее подтверждение прочтения;
|
||||
- `type=4` — исходящая копия подтверждения.
|
||||
|
||||
Ключи сообщения:
|
||||
|
||||
- `baseKey = fromLogin|toLogin|timeMs|nonce`
|
||||
- `messageKey = baseKey|messageType`
|
||||
|
||||
Логический идентификатор письма задаётся парой:
|
||||
|
||||
- `timeMs`
|
||||
- `nonce`
|
||||
|
||||
Эти поля не меняются при редактировании или удалении. Меняется только:
|
||||
|
||||
- `revisionTimeMs`
|
||||
- содержимое `encryptedBody`
|
||||
|
||||
Сервер хранит только последнюю версию записи для каждого `messageKey`.
|
||||
|
||||
## Формат контентного DM: `SHiNE_DM`
|
||||
|
||||
Префикс бинарного блока:
|
||||
|
||||
- `SHiNE_DM`
|
||||
|
||||
Поля идут в big-endian порядке:
|
||||
|
||||
1. `formatVersionMajor` (`u8`) = `1`
|
||||
2. `formatVersionMinor` (`u8`) = `0`
|
||||
3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`)
|
||||
4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`)
|
||||
5. `timeMs` (`u64`)
|
||||
6. `nonce` (`u32`)
|
||||
7. `messageType` (`u16`) — только `1` или `2`
|
||||
8. `revisionTimeMs` (`u64`)
|
||||
9. `attachmentsCount` (`u8`)
|
||||
10. `encryptedBodyLen` (`u32`)
|
||||
11. `encryptedBody` (`bytes`)
|
||||
12. `signature` (`64 bytes`, Ed25519)
|
||||
|
||||
### Ограничения
|
||||
|
||||
- `attachmentsCount` сейчас всегда должен быть `0`
|
||||
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
|
||||
- `revisionTimeMs` не может быть отрицательным
|
||||
|
||||
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
|
||||
|
||||
- `ATTACHMENTS_DISABLED`
|
||||
|
||||
## Legacy read-receipt: `SHiNE_dm2`
|
||||
|
||||
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
|
||||
|
||||
1. `toLoginLen` (`u8`) + `toLogin`
|
||||
2. `fromLoginLen` (`u8`) + `fromLogin`
|
||||
3. `timeMs` (`u64`)
|
||||
4. `nonce` (`u32`)
|
||||
5. `messageType` (`u16`) — `3` или `4`
|
||||
6. `payloadLen` (`u16`)
|
||||
7. `payloadBytes`
|
||||
8. `signature`
|
||||
|
||||
## Редактирование
|
||||
|
||||
Редактирование делается новой отправкой той же логической пары сообщения:
|
||||
|
||||
- `timeMs` и `nonce` остаются теми же;
|
||||
- `messageType` остаётся `1/2`;
|
||||
- `revisionTimeMs` становится больше;
|
||||
- `encryptedBody` содержит новую версию текста.
|
||||
|
||||
Если на сервер приходит более старая ревизия, она игнорируется.
|
||||
|
||||
Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
|
||||
|
||||
## Удаление
|
||||
|
||||
Удаление личного сообщения делается как новая ревизия того же сообщения:
|
||||
|
||||
- `timeMs` и `nonce` остаются прежними;
|
||||
- `revisionTimeMs` увеличивается;
|
||||
- `attachmentsCount = 0`;
|
||||
- `encryptedBodyLen = 0`;
|
||||
- `encryptedBody` пустой.
|
||||
|
||||
В UI такое сообщение не показывается.
|
||||
|
||||
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
|
||||
|
||||
## Поведение сервера
|
||||
|
||||
Для контентных DM сервер:
|
||||
|
||||
1. принимает пару signed-блоков `type=1/2`;
|
||||
2. валидирует формат, подпись и совпадение ключевых полей пары;
|
||||
3. проверяет, что для обеих сторон пары совпадают:
|
||||
- `fromLogin`
|
||||
- `toLogin`
|
||||
- `timeMs`
|
||||
- `nonce`
|
||||
- `revisionTimeMs`
|
||||
- `encryptedBody`
|
||||
4. делает `upsert` последней версии в `signed_messages_v2`;
|
||||
5. сбрасывает pending-доставку по сессиям для новой ревизии;
|
||||
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
|
||||
|
||||
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
|
||||
|
||||
## Хранение в БД
|
||||
|
||||
Основная таблица:
|
||||
|
||||
- `signed_messages_v2`
|
||||
|
||||
Для контентных DM в ней используются:
|
||||
|
||||
- `message_key`
|
||||
- `base_key`
|
||||
- `target_login`
|
||||
- `from_login`
|
||||
- `to_login`
|
||||
- `time_ms`
|
||||
- `nonce`
|
||||
- `message_type`
|
||||
- `revision_time_ms`
|
||||
- `raw_block`
|
||||
- `created_at_ms`
|
||||
|
||||
Отдельных таблиц файлов для DM сейчас нет.
|
||||
|
||||
## События и доставка
|
||||
|
||||
Запрос на отправку по WebSocket остаётся прежним:
|
||||
|
||||
- `SendMessagePair`
|
||||
- `ReceiveOutcomingMessage` как алиас
|
||||
|
||||
Клиент отправляет:
|
||||
|
||||
- `incomingBlobB64`
|
||||
- `outgoingBlobB64`
|
||||
|
||||
Событие в активные сессии:
|
||||
|
||||
- `SignedMessageArrived`
|
||||
|
||||
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
|
||||
|
||||
Подтверждение доставки в сессию:
|
||||
|
||||
- `AckSessionDelivery`
|
||||
|
||||
WebPush и локальные уведомления сейчас работают так:
|
||||
|
||||
- для активной онлайн-сессии приоритет у доставки по WebSocket через `SignedMessageArrived`;
|
||||
- если целевая сессия не онлайн по WebSocket, сервер может отправить WebPush с `kind=new_message`;
|
||||
- если вкладка/приложение живы, но страница скрыта (`document.visibilityState !== visible`), UI дополнительно пытается показать системное уведомление через `service worker`;
|
||||
- для активной видимой страницы UI проигрывает короткий локальный сигнал на каждое новое входящее DM, если браузер ранее разрешил аудио-контекст после пользовательского жеста;
|
||||
- для скрытой, но живой страницы UI также делает `best effort` сигнал через `vibrate()` и более длинный локальный звук;
|
||||
- эти локальные сигналы не гарантируются браузером: на мобильных устройствах они зависят от политики Chrome/Android/iOS.
|
||||
|
||||
## Правила UI
|
||||
|
||||
UI сейчас работает так:
|
||||
|
||||
- показывает только текст `encryptedBody`;
|
||||
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
|
||||
- не показывает удалённые сообщения;
|
||||
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
|
||||
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
|
||||
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
|
||||
- на видимом экране чата/приложения проигрывает короткий локальный звук на новое входящее DM;
|
||||
- при входящем DM для скрытой, но ещё живой страницы пытается поднять системное уведомление через `service worker`;
|
||||
- не показывает и не принимает вложения.
|
||||
|
||||
## Что обязательно помнить
|
||||
|
||||
- вложения в DM сейчас отключены на уровне протокола и UI;
|
||||
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
|
||||
- если позже вложения вернутся, их формат и серверная логика могут быть другими.
|
||||
@@ -18,7 +18,7 @@
|
||||
Если в начале plaintext стоит один или несколько специальных блоков формата:
|
||||
|
||||
```text
|
||||
<SHiNE:...>
|
||||
<S:...>
|
||||
```
|
||||
|
||||
то клиент трактует их как технические вставки.
|
||||
@@ -26,14 +26,14 @@
|
||||
Техническими считаются только блоки, которые:
|
||||
|
||||
- стоят строго в начале plaintext;
|
||||
- начинаются с точного префикса `<SHiNE:`;
|
||||
- начинаются с префикса `<S:` или legacy-префикса `<SHiNE:`;
|
||||
- заканчиваются первым символом `>`.
|
||||
|
||||
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
|
||||
Если текст не начинается с `<S:` или `<SHiNE:`, никакие технические вставки не ищутся.
|
||||
|
||||
## 2. Правило скрытия
|
||||
|
||||
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
|
||||
Все корректно распознанные блоки `<S:...>` или `<SHiNE:...>` в начале plaintext:
|
||||
|
||||
- не показываются пользователю как сырой текст;
|
||||
- используются клиентом для UI-логики;
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
Перед отправкой обычного текстового сообщения клиент обязан проверить:
|
||||
|
||||
- если пользовательский текст начинается с `<SHiNE:`
|
||||
- если пользовательский текст начинается с `<S:` или `<SHiNE:`
|
||||
|
||||
то клиент автоматически превращает начало в:
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
Общий вид:
|
||||
|
||||
```text
|
||||
<SHiNE:kind;key=value;key=value;...>
|
||||
<S:kind;key=value;key=value;...>
|
||||
```
|
||||
|
||||
Правила:
|
||||
@@ -69,14 +69,15 @@
|
||||
- параметры отделяются `;`;
|
||||
- ключ и значение отделяются `=`;
|
||||
- значения не экранируются в v1;
|
||||
- формат чувствителен к точному префиксу `<SHiNE:`.
|
||||
- канонический новый префикс: `<S:`;
|
||||
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
|
||||
|
||||
## 5. Тип `reply`
|
||||
|
||||
Формат:
|
||||
|
||||
```text
|
||||
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
||||
<S:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
||||
```
|
||||
|
||||
Где поле `id` — это логический идентификатор сообщения:
|
||||
@@ -91,14 +92,14 @@ fromLogin|toLogin|timeMs|nonce
|
||||
- после него может идти обычный текст ответа;
|
||||
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
||||
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
||||
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
|
||||
- в таком случае само сообщение отображается как обычный текст без блока `<S:reply...>`.
|
||||
|
||||
## 6. Тип `call`
|
||||
|
||||
### Успешный звонок
|
||||
|
||||
```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
|
||||
<SHiNE:call;v=1;status=failed;reason=offline>
|
||||
<S:call;v=1;status=failed;reason=offline>
|
||||
```
|
||||
|
||||
Допустимые причины в v1:
|
||||
@@ -130,7 +131,7 @@ fromLogin|toLogin|timeMs|nonce
|
||||
|
||||
Официальный UI SHiNE в v1:
|
||||
|
||||
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
|
||||
- скрывает все корректные `<S:...>` и legacy `<SHiNE:...>` блоки в начале plaintext;
|
||||
- для `call` строит специальный человекочитаемый текст:
|
||||
- `Звонок: M:SS`
|
||||
- `Звонок: H:MM:SS`
|
||||
|
||||
@@ -323,6 +323,7 @@ Append-only журнал всех просмотренных транзакци
|
||||
|
||||
- `pda_address TEXT PRIMARY KEY`
|
||||
- `login TEXT NOT NULL`
|
||||
- `normalized_login TEXT NOT NULL`
|
||||
- `record_number INTEGER NOT NULL`
|
||||
- `slot INTEGER NOT NULL`
|
||||
- `last_tx_signature TEXT NOT NULL`
|
||||
@@ -349,9 +350,16 @@ Append-only журнал всех просмотренных транзакци
|
||||
Индексы:
|
||||
|
||||
- уникальный индекс на `login`
|
||||
- уникальный индекс на `normalized_login`
|
||||
- индекс на `slot`
|
||||
- индекс на `last_tx_signature`
|
||||
|
||||
Правило использования:
|
||||
|
||||
- `login` хранит display-логин ровно в том регистре, как он записан в PDA;
|
||||
- `normalized_login` хранит канонический lower-case логин;
|
||||
- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case.
|
||||
|
||||
### 4. `solana_user_pda_history`
|
||||
|
||||
Append-only история всех версий пользовательских PDA.
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
Данная документация местами устарела и не соответствует реальному коду
|
||||
Данная документация местами устарела и не соответствует реальному коду
|
||||
Особенно в отношениии работы с Блокчейном
|
||||
@@ -1,12 +0,0 @@
|
||||
shine-server-bd — это библиотека реалезующая всю работу с БД:
|
||||
|
||||
хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый PostgreSQL runtime-контроллер соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java.
|
||||
|
||||
Логика структуры классов (в двух словах):
|
||||
|
||||
shine.db.DbController / shine.db.PostgresDbController — вход в runtime БД: читает `db.url/db.user/db.password`, подключается только к PostgreSQL и выдаёт новые `Connection`.
|
||||
shine.db.DatabaseInitializer — проверяет наличие `db_schema_version` и при пустой БД автоматически накатывает `postgres/schema_v1.sql`.
|
||||
|
||||
|
||||
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()).
|
||||
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, CurrentUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO, SignedMessagesDAO; плюс сервисные DAO под recovery/resync.
|
||||
@@ -1,91 +0,0 @@
|
||||
# PostgreSQL runtime schema v1
|
||||
|
||||
Дата фиксации: `2026-07-24`
|
||||
|
||||
## Назначение
|
||||
|
||||
Это целевая серверная runtime-схема PostgreSQL для SHiNE без опоры на SQLite.
|
||||
|
||||
Схема `v1` нужна как стартовая точка большого механического переноса DAO и runtime-запросов
|
||||
с существующей SQLite-логики на PostgreSQL.
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
- Источник истины по пользователям: `solana_user_pda_current`.
|
||||
- Legacy-таблицы старого runtime для пользователей и личных сообщений в новой схеме не создаются.
|
||||
- Основная таблица серверных личных сообщений: `signed_messages`.
|
||||
- Таблица `blockchain_state` сохраняется как runtime-state таблица сервера:
|
||||
она не является identity-слоем и не мигрируется как legacy SQLite data.
|
||||
- Триггеры по `blocks` сохраняются и переписываются под PostgreSQL.
|
||||
|
||||
## Таблицы sync-модуля Solana users
|
||||
|
||||
- `solana_sync_state`
|
||||
- `solana_sync_tx_history`
|
||||
- `solana_user_pda_current`
|
||||
- `solana_user_pda_history`
|
||||
|
||||
## Таблицы server runtime
|
||||
|
||||
- `db_schema_version`
|
||||
- `active_sessions`
|
||||
- `esp_pairing_settings`
|
||||
- `esp_pairing_requests`
|
||||
- `users_params`
|
||||
- `ip_geo_cache`
|
||||
- `test_free_avatar_uploads`
|
||||
- `sync_servers`
|
||||
- `blockchain_state`
|
||||
- `blocks`
|
||||
- `connections_state`
|
||||
- `message_stats`
|
||||
- `reactions_state`
|
||||
- `channel_names_state`
|
||||
- `chat200_state`
|
||||
- `chat200_members_state`
|
||||
- `user_push_tokens`
|
||||
- `signed_direct_message_replay`
|
||||
- `signed_direct_messages_history`
|
||||
- `signed_messages`
|
||||
- `signed_message_session_delivery`
|
||||
|
||||
## Триггеры
|
||||
|
||||
Схема `v1` уже включает PostgreSQL-версии триггеров:
|
||||
|
||||
- `trg_blocks_line_integrity_bi`
|
||||
- `trg_blocks_connection_state_ai`
|
||||
- `trg_blocks_message_stats_like_ai`
|
||||
- `trg_blocks_message_stats_reply_ai`
|
||||
- `trg_blocks_edit_apply_ai`
|
||||
|
||||
## Что не входит в v1
|
||||
|
||||
- полная зачистка legacy-документации, старых названий и TODO-хвостов;
|
||||
- переименование Java DAO/классов `*V2` в runtime-коде;
|
||||
- перенос прямых SQL-запросов из хэндлеров в DAO/service;
|
||||
- переключение всего runtime-кода на новый `DbProvider`.
|
||||
|
||||
Это отдельные механические шаги поверх уже утверждённой схемы.
|
||||
|
||||
## Совместимость со старыми блоками каналов
|
||||
|
||||
В runtime-сервере сознательно нет жёсткой серверной проверки
|
||||
`channelName must not contain only digits`.
|
||||
|
||||
Причина: в уже существующей истории блокчейна есть каналы с числовыми именами,
|
||||
и при холодном восстановлении сервера с пустой БД и без `.bch` такие блоки должны
|
||||
успешно переигрываться от других sync-серверов.
|
||||
|
||||
Сейчас правило "новый публичный канал не должен состоять только из цифр" остаётся
|
||||
на уровне UI/продуктовых требований и должно быть позже возвращено на сервере
|
||||
отдельным совместимым способом, который не ломает replay исторических блоков.
|
||||
|
||||
## Инициализация пустой БД
|
||||
|
||||
Если сервер подключается к PostgreSQL через `db.url=jdbc:postgresql:...` и в выбранной БД ещё нет таблицы `db_schema_version`,
|
||||
он сам автоматически накатывает `schema_v1.sql` из classpath-ресурса:
|
||||
|
||||
- ресурс: `shine-server-db/src/main/resources/postgres/schema_v1.sql`
|
||||
- признак пустой схемы: отсутствует `db_schema_version`
|
||||
- стартовая версия схемы: `1`
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
+6
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
||||
@@ -37,7 +40,9 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
<div class="initial-splash__brand">Сияние</div>
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<div id="topbar-slot" class="topbar-slot" hidden></div>
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="composer-slot" class="composer-slot" hidden></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
+147
-4
@@ -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 { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||
@@ -151,6 +156,8 @@ const routes = {
|
||||
};
|
||||
|
||||
const screenEl = document.getElementById('app-screen');
|
||||
const topbarEl = document.getElementById('topbar-slot');
|
||||
const composerEl = document.getElementById('composer-slot');
|
||||
const toolbarEl = document.getElementById('toolbar-slot');
|
||||
const appShellEl = document.querySelector('.app-shell');
|
||||
const initialSplashEl = document.getElementById('initial-splash');
|
||||
@@ -180,6 +187,8 @@ let uiVersionPeriodicIntervalId = null;
|
||||
let hiddenDmAudioContext = null;
|
||||
let hiddenDmAudioUnlocked = false;
|
||||
let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
@@ -203,6 +212,102 @@ initPwaInstallPromptHandling();
|
||||
initCallUiOverlay();
|
||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||
|
||||
function setShellMetricVar(name, valuePx) {
|
||||
if (!appShellEl) return;
|
||||
appShellEl.style.setProperty(name, `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
|
||||
}
|
||||
|
||||
function setKeyboardOffsetPx(valuePx = 0) {
|
||||
setShellMetricVar('--keyboard-offset', valuePx);
|
||||
}
|
||||
|
||||
function attachSlotHeightObserver(slotEl, cssVarName) {
|
||||
if (!slotEl || typeof ResizeObserver !== 'function') return null;
|
||||
const sync = () => {
|
||||
const visible = !slotEl.hidden && slotEl.childElementCount > 0;
|
||||
setShellMetricVar(cssVarName, visible ? slotEl.offsetHeight : 0);
|
||||
};
|
||||
const observer = new ResizeObserver(sync);
|
||||
observer.observe(slotEl);
|
||||
sync();
|
||||
return { observer, sync };
|
||||
}
|
||||
|
||||
const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height');
|
||||
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
|
||||
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
slotEl.hidden = true;
|
||||
setShellMetricVar(cssVarName, 0);
|
||||
}
|
||||
|
||||
function mountSlot(slotEl, cssVarName, node) {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
if (node instanceof Node) {
|
||||
slotEl.append(node);
|
||||
slotEl.hidden = false;
|
||||
} else {
|
||||
slotEl.hidden = true;
|
||||
}
|
||||
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
|
||||
}
|
||||
|
||||
function createChromeController(showAppChrome) {
|
||||
let topbarNode = null;
|
||||
let composerNode = null;
|
||||
let disposed = false;
|
||||
|
||||
const apply = () => {
|
||||
if (disposed) return;
|
||||
if (!showAppChrome) {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
return;
|
||||
}
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode);
|
||||
mountSlot(composerEl, '--composer-height', composerNode);
|
||||
topbarHeightObserver?.sync?.();
|
||||
composerHeightObserver?.sync?.();
|
||||
};
|
||||
|
||||
return {
|
||||
setTopbar(node = null) {
|
||||
topbarNode = node instanceof Node ? node : null;
|
||||
apply();
|
||||
},
|
||||
setComposer(node = null) {
|
||||
composerNode = node instanceof Node ? node : null;
|
||||
apply();
|
||||
},
|
||||
clear() {
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
apply();
|
||||
},
|
||||
suspend() {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
},
|
||||
resume() {
|
||||
apply();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearKeepAliveEntries() {
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -258,6 +363,23 @@ async function playDmSignal({ extended = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLockPortraitOrientation() {
|
||||
if (orientationLockInFlight) return false;
|
||||
|
||||
const orientationApi = window.screen?.orientation;
|
||||
if (!orientationApi?.lock) return false;
|
||||
|
||||
orientationLockInFlight = true;
|
||||
try {
|
||||
await orientationApi.lock('portrait');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
orientationLockInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyHiddenIncomingMessage(fromLogin, text) {
|
||||
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
|
||||
const title = `Сообщение от ${fromLogin}`;
|
||||
@@ -890,11 +1012,19 @@ function renderPageFailureFallback(pageId, error) {
|
||||
screenEl.append(wrap);
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
toolbarEl.innerHTML = '';
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
|
||||
@@ -909,15 +1039,21 @@ function renderApp() {
|
||||
}
|
||||
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
screenEl.innerHTML = '';
|
||||
const screen = page.render({ route, navigate });
|
||||
const chrome = createChromeController(showAppChrome);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
}
|
||||
@@ -925,7 +1061,6 @@ function renderApp() {
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
|
||||
@@ -933,6 +1068,7 @@ function renderApp() {
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
@@ -950,6 +1086,7 @@ function refreshToolbarOnly() {
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
@@ -1028,6 +1165,7 @@ async function ensureSessionRuntimeStarted() {
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
if (state.session.isLocalDemo) {
|
||||
setConnectionStatus('connected');
|
||||
@@ -1042,6 +1180,7 @@ async function init() {
|
||||
setSessionResetHandler(() => {
|
||||
sessionRuntimeStarted = false;
|
||||
startConnectionMonitor();
|
||||
clearKeepAliveEntries();
|
||||
if (reconnectIntervalId) {
|
||||
window.clearInterval(reconnectIntervalId);
|
||||
reconnectIntervalId = null;
|
||||
@@ -1411,8 +1550,12 @@ async function init() {
|
||||
}, { passive: true });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void tryLockPortraitOrientation();
|
||||
void checkConnectionHealth();
|
||||
});
|
||||
window.addEventListener('orientationchange', () => {
|
||||
void tryLockPortraitOrientation();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
|
||||
onSelect,
|
||||
selectedTxIds = [],
|
||||
historyOnly = false,
|
||||
persistToHistory = true,
|
||||
allowHistorySelection = true,
|
||||
allowExistingTxInput = true,
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'arweave',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
initialSha256 = '',
|
||||
initialName = '',
|
||||
fixedFile = false,
|
||||
autoOpenFileDialog = true,
|
||||
shineType = '',
|
||||
extraUploadTags = [],
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -303,7 +315,7 @@ export function openArweaveAttachmentManager({
|
||||
let turboKeyChoices = [];
|
||||
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
|
||||
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
|
||||
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave';
|
||||
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'arweave' ? 'arweave' : 'turbo';
|
||||
let selectedFile = null;
|
||||
let selectedSha256 = '';
|
||||
let selectedPreviewEnabled = false;
|
||||
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
|
||||
let selectedPreviewPriceInfo = null;
|
||||
let priceInfo = null;
|
||||
let balanceInfo = null;
|
||||
let autoOpenedFileDialog = false;
|
||||
let autoOpenedFileDialogOnce = false;
|
||||
const isAvatarMode = String(mode || '') === 'avatar';
|
||||
if (isAvatarMode && !String(uploadTransport || '').trim()) {
|
||||
selectedUploadTransport = 'turbo';
|
||||
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
|
||||
const historyPurposeMode = String(historyPurpose || '').trim();
|
||||
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||
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() {
|
||||
return selectedUploadTransport === 'turbo';
|
||||
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
});
|
||||
const item = persistToHistory
|
||||
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
})
|
||||
: {
|
||||
...attachment,
|
||||
...normalizeAttachment(attachment),
|
||||
};
|
||||
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
||||
close(resolve, item);
|
||||
}
|
||||
@@ -588,21 +615,27 @@ export function openArweaveAttachmentManager({
|
||||
|
||||
const showUpload = async () => {
|
||||
const turboMode = isTurboUpload();
|
||||
const titleText = effectiveDialogTitle
|
||||
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|
||||
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
||||
const canShowHistory = allowHistorySelection;
|
||||
const canShowExisting = allowExistingTxInput;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
<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>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Файлы до 100 KB пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
`
|
||||
: `
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
@@ -610,8 +643,8 @@ export function openArweaveAttachmentManager({
|
||||
<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>')}
|
||||
`}
|
||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -621,7 +654,7 @@ export function openArweaveAttachmentManager({
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${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>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
@@ -817,27 +850,30 @@ export function openArweaveAttachmentManager({
|
||||
storagePwd: cleanStoragePwd,
|
||||
keySource: selectedTurboKeySource,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
})
|
||||
: await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
jwk: selectedWallet()?.jwk,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
});
|
||||
finish(resolve, {
|
||||
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
||||
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
|
||||
size: selectedFile.size,
|
||||
sha256: selectedSha256,
|
||||
ar: uploaded.id,
|
||||
uploadTransport: turboMode ? 'turbo' : 'arweave',
|
||||
preview: previewUpload?.id && selectedPreviewSha256
|
||||
? {
|
||||
ar: previewUpload.id,
|
||||
@@ -853,8 +889,19 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
});
|
||||
|
||||
if (!autoOpenedFileDialog) {
|
||||
autoOpenedFileDialog = true;
|
||||
if (selectedFile && selectedSha256) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,7 +78,6 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
<span class="toolbar-label-wrap">
|
||||
<span>${item.label}</span>
|
||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
<span class="toolbar-connection-text">connected</span>
|
||||
<span class="toolbar-connection-dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-
|
||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
const MAX_ACCESS_SERVERS = 2;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -39,8 +40,9 @@ function uniqueLogins(values) {
|
||||
if (!login || seen.has(login)) return;
|
||||
seen.add(login);
|
||||
result.push(login);
|
||||
if (result.length >= MAX_ACCESS_SERVERS) return;
|
||||
});
|
||||
return result;
|
||||
return result.slice(0, MAX_ACCESS_SERVERS);
|
||||
}
|
||||
|
||||
function shortenSignature(value) {
|
||||
@@ -309,7 +311,7 @@ export function render({ navigate }) {
|
||||
addTitle.textContent = 'Добавить сервер доступа';
|
||||
const addHint = document.createElement('p');
|
||||
addHint.className = 'meta-muted';
|
||||
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'input';
|
||||
addInput.type = 'text';
|
||||
@@ -329,7 +331,9 @@ export function render({ navigate }) {
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
addButton.disabled = operationBusy
|
||||
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
const setOperationBusy = (busy) => {
|
||||
@@ -348,7 +352,9 @@ export function render({ navigate }) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
|
||||
: 'Для изменения списка понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -457,12 +463,17 @@ export function render({ navigate }) {
|
||||
}
|
||||
currentAccessServers = rows;
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? `Найдено серверов доступа: ${rows.length}`
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
if (rows.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
|
||||
}
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
||||
}
|
||||
};
|
||||
@@ -654,6 +665,10 @@ export function render({ navigate }) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
|
||||
@@ -157,7 +157,7 @@ export function render({ navigate }) {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n'
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
@@ -24,6 +24,12 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
|
||||
const MSG_SUBTYPE_TEXT_SERVICE = 120;
|
||||
const MSG_SUBTYPE_TEXT_COURSE = 130;
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingThreadScroll = new Map();
|
||||
@@ -209,6 +215,46 @@ function resolveChannelDisplayName(channelSelector) {
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
}
|
||||
|
||||
function resolveChannelHeadingFromNode(node) {
|
||||
const info = node?.channelInfo;
|
||||
const ownerBch = String(info?.ownerBlockchainName || '').trim();
|
||||
const rootBlockNumber = Number(info?.channelRoot?.blockNumber);
|
||||
if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return '';
|
||||
if (rootBlockNumber === 0) return 'История пользователя';
|
||||
|
||||
const label = resolveChannelDisplayName({
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelRootBlockNumber: rootBlockNumber,
|
||||
channelRootBlockHash: '0',
|
||||
});
|
||||
const slashIndex = label.indexOf('/');
|
||||
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim();
|
||||
if (!channelName) return 'Сообщение в канале';
|
||||
return `Сообщение в канале ${channelName}`;
|
||||
}
|
||||
|
||||
function getChannelMessageTypeMeta(msgSubType) {
|
||||
switch (Number(msgSubType || 0)) {
|
||||
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||
return { label: 'Упражнение', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||
return { label: 'Услуга', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_COURSE:
|
||||
return { label: 'Курс', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||
return { label: 'Оглавление' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableAsChannelPostSubType(msgSubType) {
|
||||
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST
|
||||
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE
|
||||
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE
|
||||
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE;
|
||||
}
|
||||
|
||||
function extractChannelContextFromThreadPayload(payload) {
|
||||
const focusInfo = payload?.focus?.channelInfo;
|
||||
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
|
||||
@@ -286,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) {
|
||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||
@@ -417,10 +482,10 @@ function openBlockchainDetailsModal(details) {
|
||||
|
||||
function resolveNodeText(node) {
|
||||
return firstNonEmptyText(
|
||||
latestVersionText(node?.versions),
|
||||
node?.text,
|
||||
node?.message,
|
||||
node?.body,
|
||||
latestVersionText(node?.versions),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,13 +508,22 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
@@ -486,7 +560,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -499,7 +573,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -695,7 +769,6 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card channel-message-card';
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
||||
@@ -703,11 +776,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const ratings = Number(node?.ratingsCount || 0);
|
||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
||||
const msgSubType = Number(node?.msgSubType || 0);
|
||||
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
|
||||
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
|
||||
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
||||
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
|
||||
const parsedText = parseMessageAttachments(text);
|
||||
if (isRating) card.classList.add('is-rating');
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@@ -720,6 +797,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
const avatar = createThreadAvatar(author);
|
||||
|
||||
@@ -727,13 +806,16 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'channel-message-title author-line';
|
||||
const titleMain = document.createElement('div');
|
||||
titleMain.className = 'author-line-main';
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'author-line-login';
|
||||
loginEl.textContent = author;
|
||||
const numberEl = document.createElement('span');
|
||||
numberEl.className = 'author-line-num';
|
||||
numberEl.textContent = `· #${localNumber}`;
|
||||
title.append(loginEl, numberEl);
|
||||
titleMain.append(loginEl, numberEl);
|
||||
title.append(titleMain);
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
@@ -755,6 +837,25 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
headRow.append(authorTile);
|
||||
const typeMeta = getChannelMessageTypeMeta(node?.msgSubType);
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onStatusAction(node);
|
||||
});
|
||||
} else {
|
||||
typeButton.classList.add('is-static');
|
||||
typeButton.disabled = true;
|
||||
}
|
||||
headRow.append(typeButton);
|
||||
}
|
||||
|
||||
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
|
||||
|
||||
@@ -775,13 +876,19 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.append(deleted);
|
||||
return card;
|
||||
} else {
|
||||
card.append(authorTile);
|
||||
card.append(headRow);
|
||||
if (parsedText.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedText.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: node?.createdAtMs,
|
||||
}));
|
||||
}
|
||||
if (isRating) {
|
||||
const ratingBadge = document.createElement('span');
|
||||
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
|
||||
ratingBadge.textContent = 'Оценка';
|
||||
card.append(ratingBadge);
|
||||
}
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedText.text;
|
||||
@@ -855,6 +962,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
});
|
||||
});
|
||||
const ratingButton = document.createElement('button');
|
||||
ratingButton.type = 'button';
|
||||
ratingButton.className = 'channel-action-item thread-rating-btn';
|
||||
ratingButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||
<span class="channel-action-label">Оценка</span>
|
||||
<span class="channel-action-counter">${ratings}</span>
|
||||
`;
|
||||
setActionTitle(ratingButton, 'Оценка');
|
||||
ratingButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
mode: 'rating',
|
||||
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
||||
});
|
||||
});
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
@@ -872,7 +997,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
@@ -959,7 +1084,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
normalized.forEach((branch, index) => {
|
||||
try {
|
||||
const nodeNumber = nextNumber();
|
||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
|
||||
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
wrap.append(row);
|
||||
@@ -1008,10 +1133,11 @@ function renderSkeleton(screen) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
let activeResolvedChannelLabel = channelDisplayName;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
@@ -1020,14 +1146,30 @@ export function render({ navigate, route }) {
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [],
|
||||
});
|
||||
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderButton.classList.add('channel-header-route-btn');
|
||||
header.classList.add('channel-thread-topbar');
|
||||
const headerLeft = header.querySelector('.header-left');
|
||||
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;
|
||||
headerLeft.append(threadHeaderButton);
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
@@ -1104,6 +1246,15 @@ export function render({ navigate, route }) {
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onRating: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
@@ -1194,7 +1345,7 @@ export function render({ navigate, route }) {
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(header, statusBox);
|
||||
screen.append(statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
@@ -1302,6 +1453,7 @@ export function render({ navigate, route }) {
|
||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||
}
|
||||
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
@@ -1326,12 +1478,9 @@ export function render({ navigate, route }) {
|
||||
if (ancestors.length) {
|
||||
ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'История выше (на что это ответ)';
|
||||
ancestorsWrap.append(title);
|
||||
ancestors.forEach((node, index) => {
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
|
||||
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1350,7 +1499,7 @@ export function render({ navigate, route }) {
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы';
|
||||
descendantsTitle.textContent = 'Ответы и оценки';
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
@@ -1358,7 +1507,7 @@ export function render({ navigate, route }) {
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов пока нет.';
|
||||
empty.textContent = 'Ответов и оценок пока нет.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
@@ -33,10 +32,28 @@ import {
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
|
||||
const MSG_SUBTYPE_TEXT_SERVICE = 120;
|
||||
const MSG_SUBTYPE_TEXT_COURSE = 130;
|
||||
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
|
||||
const MSG_SUBTYPE_STATUS_LEARNED = 20;
|
||||
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
|
||||
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
|
||||
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
|
||||
const MSG_SUBTYPE_STATUS_STARTED = 120;
|
||||
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
|
||||
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
|
||||
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
|
||||
const DIARY_CHANNEL_NAME = 'diary';
|
||||
const DIARY_CHANNEL_DISPLAY_NAME = 'Дневник';
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingScrollByRoute = new Map();
|
||||
@@ -287,6 +304,82 @@ function resolveMessageTimestampMs(message) {
|
||||
);
|
||||
}
|
||||
|
||||
function getChannelMessageTypeMeta(msgSubType) {
|
||||
switch (Number(msgSubType || 0)) {
|
||||
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||
return { label: 'Упражнение', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||
return { label: 'Услуга', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_COURSE:
|
||||
return { label: 'Курс', actionable: true };
|
||||
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||
return { label: 'Оглавление' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isEntrypointSubType(msgSubType) {
|
||||
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT;
|
||||
}
|
||||
|
||||
function isDiarySelector(selector) {
|
||||
return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME;
|
||||
}
|
||||
|
||||
function isStatusActionSubType(msgSubType) {
|
||||
return new Set([
|
||||
MSG_SUBTYPE_STATUS_DONE_ONCE,
|
||||
MSG_SUBTYPE_STATUS_LEARNED,
|
||||
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
|
||||
MSG_SUBTYPE_STATUS_CONFIRMED,
|
||||
MSG_SUBTYPE_STATUS_INTERESTED,
|
||||
MSG_SUBTYPE_STATUS_STARTED,
|
||||
MSG_SUBTYPE_STATUS_IN_STUDY,
|
||||
MSG_SUBTYPE_STATUS_ABANDONED,
|
||||
MSG_SUBTYPE_STATUS_COMPLETED,
|
||||
]).has(Number(msgSubType || 0));
|
||||
}
|
||||
|
||||
function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) {
|
||||
const status = Number(statusSubType || 0);
|
||||
const target = Number(targetMsgSubType || 0);
|
||||
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' };
|
||||
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' };
|
||||
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' };
|
||||
if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' };
|
||||
if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' };
|
||||
if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' };
|
||||
if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' };
|
||||
if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' };
|
||||
if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' };
|
||||
return { label: 'Действие' };
|
||||
}
|
||||
|
||||
function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
switch (Number(targetMsgSubType || 0)) {
|
||||
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' },
|
||||
];
|
||||
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' },
|
||||
];
|
||||
case MSG_SUBTYPE_TEXT_COURSE:
|
||||
return [
|
||||
{ subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' },
|
||||
{ subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
@@ -303,7 +396,19 @@ function createChannelAvatarElement(channel, size = 72) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) {
|
||||
function openChannelMetaDetailsModal({
|
||||
title = 'Данные канала',
|
||||
channel = {},
|
||||
canEdit = false,
|
||||
onEdit = null,
|
||||
changedAtMs = 0,
|
||||
} = {}) {
|
||||
const avatarState = String(channel.avaAr || '').trim()
|
||||
? 'Установлен'
|
||||
: 'Не установлен';
|
||||
const changedAtLabel = changedAtMs
|
||||
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
|
||||
: '—';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="about-channel-modal">
|
||||
@@ -313,9 +418,14 @@ function openChannelMetaDetailsModal({ title = 'Данные канала', chan
|
||||
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
|
||||
</div>
|
||||
<div id="about-channel-avatar-slot"></div>
|
||||
<p><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}</strong></p>
|
||||
<p class="meta-muted">${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}</p>
|
||||
<p class="meta-muted">${escapeHtml(channel.description || 'Описание не задано.')}</p>
|
||||
<div class="channel-meta-details-grid">
|
||||
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
||||
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
|
||||
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
|
||||
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')}</strong>
|
||||
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
|
||||
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
||||
</div>
|
||||
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,6 +447,7 @@ function openAboutChannelModal(channel, options = {}) {
|
||||
channel,
|
||||
canEdit: options.canEdit === true,
|
||||
onEdit: options.onEdit,
|
||||
changedAtMs: Number(channel?.metaUpdatedAtMs || 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -541,13 +652,22 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
@@ -584,7 +704,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -597,7 +717,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -626,6 +746,191 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-status-action-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${escapeHtml(title || 'Новое действие')}</h3>
|
||||
<p class="meta-muted">Если хотите, можете добавить комментарий</p>
|
||||
<textarea id="channel-status-action-text" class="input" rows="5" maxlength="2000" placeholder="Комментарий"></textarea>
|
||||
<div class="meta-muted inline-error" id="channel-status-action-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-status-action-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="channel-status-action-submit" type="button">${escapeHtml(submitLabel || 'Сохранить')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#channel-status-action-text');
|
||||
const errorEl = root.querySelector('#channel-status-action-error');
|
||||
const submitEl = root.querySelector('#channel-status-action-submit');
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (submitEl) {
|
||||
submitEl.disabled = inFlight;
|
||||
submitEl.textContent = inFlight ? 'Сохраняем...' : (submitLabel || 'Сохранить');
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#channel-status-action-cancel')?.addEventListener('click', close);
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit(String(textEl?.value || '').trim());
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rows = (Array.isArray(options) ? options : [])
|
||||
.map((item, index) => `
|
||||
<button class="channel-menu-item channel-status-action-item" data-status-index="${index}" type="button">
|
||||
${escapeHtml(item.label || 'Действие')}
|
||||
</button>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-status-menu-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${escapeHtml(targetLabel || 'Действия')}</h3>
|
||||
<div class="stack">${rows}</div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-status-menu-cancel" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#channel-status-menu-cancel')?.addEventListener('click', close);
|
||||
root.querySelectorAll('[data-status-index]').forEach((button) => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
const idx = Number(event.currentTarget?.dataset?.statusIndex || -1);
|
||||
const option = options[idx];
|
||||
if (!option) return;
|
||||
close();
|
||||
await onSelect(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sortPostsByTimeDesc(posts = []) {
|
||||
return [...posts].sort((a, b) => {
|
||||
const byTime = Number(b?.timestampMs || 0) - Number(a?.timestampMs || 0);
|
||||
if (byTime !== 0) return byTime;
|
||||
return Number(b?.messageRef?.blockNumber || 0) - Number(a?.messageRef?.blockNumber || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function getEntrypointPosts(posts = []) {
|
||||
return sortPostsByTimeDesc((Array.isArray(posts) ? posts : []).filter((post) => isEntrypointSubType(post?.msgSubType)));
|
||||
}
|
||||
|
||||
function flashAndScrollToMessage(messageRef) {
|
||||
const key = messageRefKey(messageRef);
|
||||
if (!key) return false;
|
||||
const cards = Array.from(document.querySelectorAll('.channel-message-card[data-message-key]'));
|
||||
const target = cards.find((card) => card.dataset.messageKey === key);
|
||||
if (!target) return false;
|
||||
target.classList.remove('is-focus-flash');
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
|
||||
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
|
||||
return true;
|
||||
}
|
||||
|
||||
function openEntrypointMenuModal({ onShowHistory }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-entrypoint-menu-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Оглавление канала</h3>
|
||||
<div class="stack">
|
||||
<button class="channel-menu-item channel-status-action-item" id="channel-entrypoint-history" type="button">Просмотреть историю изменений оглавления</button>
|
||||
</div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-entrypoint-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#channel-entrypoint-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#channel-entrypoint-history')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
if (typeof onShowHistory === 'function') onShowHistory();
|
||||
});
|
||||
}
|
||||
|
||||
function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const items = getEntrypointPosts(posts);
|
||||
const cleanTitle = String(channelTitle || '').trim() || 'канала';
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-entrypoint-history-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">История оглавления</h3>
|
||||
<p class="meta-muted">Показаны все версии оглавления канала ${escapeHtml(cleanTitle)}</p>
|
||||
<div class="entrypoint-history-list" id="entrypoint-history-list"></div>
|
||||
<button class="secondary-btn" id="entrypoint-history-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const list = root.querySelector('#entrypoint-history-list');
|
||||
if (list) {
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
|
||||
list.append(empty);
|
||||
} else {
|
||||
items.forEach((post) => {
|
||||
const parsed = parseMessageAttachments(post.body);
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'entrypoint-history-item';
|
||||
item.innerHTML = `
|
||||
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
|
||||
<span>#${escapeHtml(post.localNumber || '—')}</span>
|
||||
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
if (typeof onSelect === 'function') onSelect(post);
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
root.querySelector('#entrypoint-history-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
@@ -709,7 +1014,16 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
<p class="meta-muted">${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="draft-attachments" id="channel-message-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="channel-message-tools">
|
||||
<select id="channel-message-type" class="input channel-message-type-select">
|
||||
<option value="${10}">Пост</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
</select>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
@@ -720,6 +1034,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#channel-message-text');
|
||||
const typeEl = root.querySelector('#channel-message-type');
|
||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const submitEl = root.querySelector('#channel-message-submit');
|
||||
@@ -730,6 +1045,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (typeEl) typeEl.disabled = inFlight;
|
||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
@@ -743,6 +1059,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
if (inFlight) return;
|
||||
|
||||
const body = String(textEl?.value || '').trim();
|
||||
const msgSubType = Number(typeEl?.value || 10);
|
||||
if (!body && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||
return;
|
||||
@@ -752,7 +1069,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(body, attachments));
|
||||
await onSubmit({
|
||||
text: composeMessageWithAttachments(body, attachments),
|
||||
msgSubType,
|
||||
});
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
@@ -880,6 +1200,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
const hasRef = !!(messageBch && blockNumber != null && blockHash);
|
||||
|
||||
const resolvedText = resolveMessageText(message);
|
||||
const msgSubType = Number(message?.msgSubType || 0);
|
||||
const isStatusAction = isStatusActionSubType(msgSubType);
|
||||
const messageRef = hasRef
|
||||
? {
|
||||
blockchainName: messageBch,
|
||||
@@ -895,15 +1217,18 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
return {
|
||||
localNumber,
|
||||
authorLogin: message?.authorLogin || 'автор',
|
||||
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
|
||||
body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')),
|
||||
versionsTotal: Number(message?.versionsTotal || 1),
|
||||
versions: Array.isArray(message?.versions) ? message.versions : [],
|
||||
likesCount: Number(message?.likesCount || 0),
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
ratingsCount: Number(message?.ratingsCount || 0),
|
||||
timestampMs: resolveMessageTimestampMs(message),
|
||||
messageRef,
|
||||
rawMessage: message,
|
||||
msgSubType: Number(message?.msgSubType || 0),
|
||||
msgSubType,
|
||||
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction,
|
||||
isStatusAction,
|
||||
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
|
||||
? {
|
||||
blockchainName: String(message.targetBlockchainName).trim(),
|
||||
@@ -911,6 +1236,11 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
blockHash: normalizeMessageHash(message?.targetBlockHash),
|
||||
}
|
||||
: null,
|
||||
targetMsgSubType: Number(message?.targetMsgSubType || 0),
|
||||
targetText: String(message?.targetText || '').trim(),
|
||||
targetAuthorLogin: String(message?.targetAuthorLogin || '').trim(),
|
||||
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
|
||||
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
||||
};
|
||||
@@ -939,6 +1269,45 @@ async function loadFromApi(route, channelId) {
|
||||
};
|
||||
|
||||
let selector = buildSelectorFromRoute(route, channelId);
|
||||
if (selector?.ownerBlockchainName && selector?.channelName && isDiarySelector(selector)) {
|
||||
if (!isAuthorized) {
|
||||
throw new Error('Дневник доступен только после входа.');
|
||||
}
|
||||
const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc');
|
||||
const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
|
||||
const posts = diaryMessages
|
||||
.map((message, index) => mapApiMessageToPost(message, selector, index + 1))
|
||||
.sort((a, b) => {
|
||||
const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0);
|
||||
if (byTime !== 0) return byTime;
|
||||
const aNum = Number(a?.messageRef?.blockNumber || 0);
|
||||
const bNum = Number(b?.messageRef?.blockNumber || 0);
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||||
|
||||
return {
|
||||
channel: {
|
||||
name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME,
|
||||
displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(),
|
||||
displayName: 'Дневника',
|
||||
description: String(diaryPayload?.channel?.channelDescription || '').trim(),
|
||||
avaAr: '',
|
||||
avaSha256: '',
|
||||
avaSize: 0,
|
||||
metaUpdatedAtMs: 0,
|
||||
ownerName: currentSessionLogin,
|
||||
},
|
||||
posts,
|
||||
metaEvents: [],
|
||||
reverseChannelMissingWarning: '',
|
||||
isOwnChannel: true,
|
||||
isSubscribed: true,
|
||||
isDiary: true,
|
||||
selector,
|
||||
};
|
||||
}
|
||||
|
||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||
@@ -1181,15 +1550,14 @@ function renderChannelMetaEventCard(event) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? `Создан канал ${event.title || event.name || ''}`.trim()
|
||||
: 'Изменён профиль канала';
|
||||
? 'Создан канал'
|
||||
: 'Изменено описание канала';
|
||||
card.innerHTML = `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<small>${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')}</small>
|
||||
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён',
|
||||
title: label,
|
||||
channel: {
|
||||
name: event.name,
|
||||
displayTitle: event.title,
|
||||
@@ -1199,6 +1567,7 @@ function renderChannelMetaEventCard(event) {
|
||||
avaSize: event.avaSize,
|
||||
ownerName: event.ownerName,
|
||||
},
|
||||
changedAtMs: event.timestampMs,
|
||||
});
|
||||
});
|
||||
return card;
|
||||
@@ -1209,6 +1578,9 @@ function renderPostCard(post, {
|
||||
selector,
|
||||
onToggleLike,
|
||||
onReply,
|
||||
onRating,
|
||||
onStatusAction,
|
||||
onOpenEntrypointMenu,
|
||||
onRepost,
|
||||
onShare,
|
||||
onEdit,
|
||||
@@ -1217,6 +1589,8 @@ function renderPostCard(post, {
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack channel-message-card';
|
||||
if (post.isRating) card.classList.add('is-rating');
|
||||
if (selector && isDiarySelector(selector)) card.classList.add('is-diary-entry');
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
@@ -1226,9 +1600,13 @@ function renderPostCard(post, {
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'channel-message-title author-line';
|
||||
const titleMain = document.createElement('div');
|
||||
titleMain.className = 'author-line-main';
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'author-line-login';
|
||||
loginEl.textContent = post.authorLogin;
|
||||
@@ -1236,12 +1614,12 @@ function renderPostCard(post, {
|
||||
const numberEl = document.createElement('span');
|
||||
numberEl.className = 'author-line-num';
|
||||
numberEl.textContent = `· #${post.localNumber}`;
|
||||
titleMain.append(loginEl, numberEl);
|
||||
title.append(titleMain);
|
||||
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
|
||||
title.append(loginEl, numberEl);
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
@@ -1260,6 +1638,31 @@ function renderPostCard(post, {
|
||||
}
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
headRow.append(authorTile);
|
||||
const typeMeta = getChannelMessageTypeMeta(post.msgSubType);
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
onStatusAction(post);
|
||||
});
|
||||
} else if (isEntrypointSubType(post.msgSubType) && typeof onOpenEntrypointMenu === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
onOpenEntrypointMenu(post);
|
||||
});
|
||||
} else {
|
||||
typeButton.classList.add('is-static');
|
||||
typeButton.disabled = true;
|
||||
}
|
||||
headRow.append(typeButton);
|
||||
}
|
||||
authorTile.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const cleanLogin = String(post.authorLogin || '').trim();
|
||||
@@ -1287,13 +1690,39 @@ function renderPostCard(post, {
|
||||
card.append(deleted);
|
||||
return card;
|
||||
} else {
|
||||
card.append(authorTile);
|
||||
card.append(headRow);
|
||||
if (parsedBody.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: post.timestampMs,
|
||||
}));
|
||||
}
|
||||
if (post.isRating) {
|
||||
const ratingBadge = document.createElement('span');
|
||||
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
|
||||
ratingBadge.textContent = 'Оценка';
|
||||
card.append(ratingBadge);
|
||||
}
|
||||
if (post.isStatusAction && selector && isDiarySelector(selector)) {
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status';
|
||||
statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label;
|
||||
card.append(statusBadge);
|
||||
if (post.targetText || post.targetAuthorLogin) {
|
||||
const targetPreview = document.createElement('div');
|
||||
targetPreview.className = 'channel-message-target-preview';
|
||||
const targetType = getChannelMessageTypeMeta(post.targetMsgSubType)?.label || 'Материал';
|
||||
const targetParsed = parseDmTechBlocks(String(post.targetText || '').trim());
|
||||
const targetText = String(targetParsed.displayText || targetParsed.visibleText || '').trim();
|
||||
const targetAuthor = String(post.targetAuthorLogin || '').trim();
|
||||
targetPreview.innerHTML = `
|
||||
<strong>${escapeHtml(targetType)}</strong>
|
||||
<span>${escapeHtml(targetAuthor || 'автор')}</span>
|
||||
<p>${escapeHtml(targetText || 'Без текста')}</p>
|
||||
`;
|
||||
card.append(targetPreview);
|
||||
}
|
||||
}
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedBody.text;
|
||||
@@ -1357,9 +1786,27 @@ function renderPostCard(post, {
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
const ratingButton = document.createElement('button');
|
||||
ratingButton.type = 'button';
|
||||
ratingButton.className = 'channel-action-item channel-action-rating';
|
||||
ratingButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||
<span class="channel-action-label">Оценка</span>
|
||||
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
||||
`;
|
||||
setActionTitle(ratingButton, 'Оценка');
|
||||
ratingButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate,
|
||||
mode: 'rating',
|
||||
onSubmit: async (text) => onRating(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton);
|
||||
actions.append(likeButton, replyButton, ratingButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
@@ -1377,7 +1824,7 @@ function renderPostCard(post, {
|
||||
});
|
||||
|
||||
actions.append(shareButton);
|
||||
if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
originalBtn.className = 'channel-action-item';
|
||||
@@ -1506,6 +1953,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
onRating: handlers.onRating,
|
||||
onStatusAction: handlers.onStatusAction,
|
||||
onOpenEntrypointMenu: handlers.onOpenEntrypointMenu,
|
||||
onRepost: handlers.onRepost,
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
@@ -1519,7 +1969,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ждем ваших начинаний';
|
||||
empty.textContent = channelData.isDiary
|
||||
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
|
||||
: 'Ждем ваших начинаний';
|
||||
feed.append(empty);
|
||||
}
|
||||
|
||||
@@ -1533,7 +1985,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
backButton.textContent = 'Назад к каналам';
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
if (channelData.isOwnChannel) {
|
||||
if (channelData.isDiary) {
|
||||
screen.append(feed, backButton);
|
||||
} else if (channelData.isOwnChannel) {
|
||||
screen.append(feed, addMessageButton);
|
||||
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
||||
screen.append(actionButton, feed, backButton);
|
||||
@@ -1541,7 +1995,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
@@ -1555,7 +2009,7 @@ function renderSkeleton(screen) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const channelId = route.params.channelId || '';
|
||||
const routeSelector = buildSelectorFromRoute(route, channelId);
|
||||
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
|
||||
@@ -1581,14 +2035,22 @@ export function render({ navigate, route }) {
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
],
|
||||
});
|
||||
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
|
||||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.classList.add('channel-header-route-btn');
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const rerender = () => {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
@@ -1651,6 +2113,47 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
const onRating = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
|
||||
|
||||
const scrollTarget = messageRefKey(messageRef);
|
||||
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||||
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
rerender();
|
||||
};
|
||||
|
||||
const onStatusAction = async (post) => {
|
||||
const options = getStatusActionOptionsForTarget(post?.msgSubType);
|
||||
const typeMeta = getChannelMessageTypeMeta(post?.msgSubType);
|
||||
if (!options.length || !typeMeta || !post?.messageRef) return;
|
||||
openStatusActionMenuModal({
|
||||
targetLabel: typeMeta.label,
|
||||
options,
|
||||
onSelect: async (option) => {
|
||||
openStatusActionCommentModal({
|
||||
title: option.modalTitle,
|
||||
submitLabel: option.label,
|
||||
onSubmit: async (text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockStatusAction({
|
||||
login,
|
||||
storagePwd,
|
||||
message: post.messageRef,
|
||||
text,
|
||||
statusSubType: option.subType,
|
||||
});
|
||||
softHaptic(14);
|
||||
showToast(`${option.label} сохранено`);
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const loadOwnedChannelsForRepost = async (login) => {
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
||||
@@ -1723,7 +2226,7 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddPost = async (bodyText) => {
|
||||
const onAddPost = async (bodyText, msgSubType = 10) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
@@ -1734,6 +2237,7 @@ export function render({ navigate, route }) {
|
||||
storagePwd,
|
||||
channel: activeSelector,
|
||||
text: bodyText,
|
||||
msgSubType,
|
||||
});
|
||||
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
@@ -1744,7 +2248,8 @@ export function render({ navigate, route }) {
|
||||
|
||||
const onEditPost = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
const isDiaryEdit = isDiarySelector(activeSelector);
|
||||
if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
}
|
||||
await authService.addBlockEditMessage({
|
||||
@@ -1752,8 +2257,8 @@ export function render({ navigate, route }) {
|
||||
storagePwd,
|
||||
message: messageRef,
|
||||
text,
|
||||
isChannelPost: true,
|
||||
channel: activeSelector,
|
||||
isChannelPost: !isDiaryEdit,
|
||||
channel: isDiaryEdit ? null : activeSelector,
|
||||
});
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
@@ -1779,7 +2284,6 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
@@ -1791,13 +2295,25 @@ export function render({ navigate, route }) {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
activeSelector = apiData?.selector || null;
|
||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
||||
const openEntrypointHistory = () => {
|
||||
openEntrypointHistoryModal({
|
||||
channelTitle: titleLabel,
|
||||
posts: apiData?.posts,
|
||||
onSelect: (post) => {
|
||||
if (!flashAndScrollToMessage(post?.messageRef)) {
|
||||
showToast('Не удалось найти запись оглавления в ленте');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = titleLabel;
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel, {
|
||||
canEdit: apiData?.isOwnChannel === true,
|
||||
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
|
||||
onEdit: () => openEditChannelModal({
|
||||
channel: apiData.channel,
|
||||
onSave: onEditChannelMeta,
|
||||
@@ -1805,15 +2321,26 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
};
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||
channelEntrypointButton.disabled = !canShowEntrypointButton;
|
||||
channelEntrypointButton.onclick = () => {
|
||||
const latestEntrypoint = entrypointPosts[0];
|
||||
if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) {
|
||||
showToast('Не удалось найти актуальное оглавление');
|
||||
}
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
onAddMessage: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
onSubmit: async (bodyText) => {
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText);
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
@@ -1837,6 +2364,27 @@ export function render({ navigate, route }) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
||||
}
|
||||
},
|
||||
onRating: async (messageRef, text) => {
|
||||
try {
|
||||
await onRating(messageRef, text);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось отправить оценку.'));
|
||||
}
|
||||
},
|
||||
onStatusAction: async (post) => {
|
||||
try {
|
||||
await onStatusAction(post);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось записать действие.'));
|
||||
}
|
||||
},
|
||||
onOpenEntrypointMenu: () => {
|
||||
openEntrypointMenuModal({
|
||||
onShowHistory: openEntrypointHistory,
|
||||
});
|
||||
},
|
||||
onRepost: async (messageRef) => {
|
||||
try {
|
||||
await onRepost(messageRef);
|
||||
|
||||
@@ -14,17 +14,27 @@ import {
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
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_PERSONAL = 100;
|
||||
const DIARY_CHANNEL_NAME = 'diary';
|
||||
const DIARY_DISPLAY_NAME = 'Дневник';
|
||||
|
||||
const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
|
||||
function cleanChannelMessagePreview(text) {
|
||||
const parsed = parseMessageAttachments(text);
|
||||
return String(parsed.text || '').trim() || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|
||||
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
}
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
@@ -223,6 +233,26 @@ function normalizeComparableLogin(value) {
|
||||
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) {
|
||||
const expected = normalizeComparableLogin(targetLogin);
|
||||
if (!expected) return false;
|
||||
@@ -612,6 +642,11 @@ function openChannelFinderModal({ navigate }) {
|
||||
function mapMockGroups() {
|
||||
const mapRow = (channel) => ({
|
||||
...channel,
|
||||
sourceBucket: channel.kind === 'subscribed'
|
||||
? 'followedChannels'
|
||||
: channel.kind === 'followed-user-channel'
|
||||
? 'followedUsers'
|
||||
: 'own',
|
||||
route: makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
ownerBlockchainName: String(channel.ownerName || ''),
|
||||
@@ -663,6 +698,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
sourceBucket: bucketKey,
|
||||
route: buildChannelRouteFromSummary(summary, rowId),
|
||||
ownerName: ownerLogin,
|
||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||
@@ -698,11 +734,65 @@ function pullCreateSuccessFlash() {
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiFeed(feed, notificationsState) {
|
||||
function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) {
|
||||
const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
|
||||
if (!messages.length) return null;
|
||||
|
||||
const ownerBlockchainName = String(
|
||||
diaryPayload?.channel?.ownerBlockchainName
|
||||
|| ownRows[0]?.channel?.ownerBlockchainName
|
||||
|| ''
|
||||
).trim();
|
||||
if (!ownerBlockchainName) return null;
|
||||
|
||||
const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim();
|
||||
const lastMessage = messages[messages.length - 1] || null;
|
||||
const rowId = 'own-diary';
|
||||
index[rowId] = { diary: true, payload: diaryPayload };
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
sourceBucket: 'own',
|
||||
route: makeShineChannelRoute({
|
||||
ownerLogin,
|
||||
ownerBlockchainName,
|
||||
channelName: DIARY_CHANNEL_NAME,
|
||||
}),
|
||||
ownerName: ownerLogin || 'я',
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: 0,
|
||||
channelRootBlockHash: '0',
|
||||
avatar: 'Д',
|
||||
avaAr: '',
|
||||
title: DIARY_DISPLAY_NAME,
|
||||
technicalLabel: 'Виртуальная лента Дневника',
|
||||
channelName: DIARY_CHANNEL_NAME,
|
||||
displayTitle: DIARY_DISPLAY_NAME,
|
||||
channelDescription: 'История действий по упражнениям, услугам и курсам',
|
||||
channelTypeCode: 900,
|
||||
channelTypeVersion: 1,
|
||||
messagePreview: String(lastMessage?.text || '').trim()
|
||||
? cleanChannelMessagePreview(lastMessage?.text)
|
||||
: 'Новая запись в Дневнике',
|
||||
messagesCount: messages.length,
|
||||
unreadCount: 0,
|
||||
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
|
||||
isOwnChannel: true,
|
||||
isSubscribed: false,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
pending: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mapApiFeed(feed, notificationsState, diaryPayload = null) {
|
||||
const index = {};
|
||||
const ownChannels = (feed?.ownedChannels || [])
|
||||
.filter(isVisibleChannelSummary)
|
||||
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
|
||||
const diaryChannel = diaryPayload
|
||||
? buildDiaryChannelRow(diaryPayload, feed?.ownedChannels || [], notificationsState, index)
|
||||
: null;
|
||||
if (diaryChannel) ownChannels.unshift(diaryChannel);
|
||||
const followedUserChannels = (feed?.followedUsersChannels || [])
|
||||
.filter(isVisibleChannelSummary)
|
||||
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
|
||||
@@ -833,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 }) {
|
||||
closeChannelMenu(listState, false);
|
||||
|
||||
@@ -1002,7 +1184,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.innerHTML = '';
|
||||
|
||||
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) {
|
||||
container.append(renderEmptyState());
|
||||
@@ -1092,11 +1278,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
||||
const baseClass = `primary-btn channels-bottom-action${isTabEmpty ? ' is-empty-lift' : ''}`;
|
||||
button.textContent = 'Поиск каналов';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
@@ -1126,7 +1313,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
const groups = mapApiFeed(feed, listState.notificationsState);
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
} catch {
|
||||
diaryPayload = null;
|
||||
}
|
||||
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
|
||||
|
||||
listState.channels = toListModel(groups);
|
||||
setChannelsFeed(feed, groups.index);
|
||||
@@ -1151,7 +1344,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
@@ -1163,10 +1356,12 @@ export function render({ navigate, route }) {
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
menuCleanup: null,
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
@@ -1209,8 +1404,31 @@ export function render({ navigate, route }) {
|
||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||
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);
|
||||
topBarRight.append(findChannelBtn, createInMyBtn);
|
||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
@@ -1219,18 +1437,9 @@ export function render({ navigate, route }) {
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
const rerenderList = () => {
|
||||
try {
|
||||
const expectedPath = '/channels';
|
||||
if (window.location.pathname !== expectedPath) {
|
||||
window.history.replaceState({}, '', expectedPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore history errors
|
||||
}
|
||||
|
||||
const isTabEmpty = !(listState.channels || []).length;
|
||||
|
||||
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
|
||||
renderListContent({
|
||||
screen,
|
||||
@@ -1240,31 +1449,23 @@ export function render({ navigate, route }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
topTitle.textContent = 'Каналы';
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
findChannelBtn.style.display = '';
|
||||
createInMyBtn.style.display = '';
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
navigate,
|
||||
isTabEmpty,
|
||||
});
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
screen.append(topBarEl, contentEl, bottomCta);
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
navigate,
|
||||
isTabEmpty: true,
|
||||
});
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
@@ -1273,6 +1474,7 @@ export function render({ navigate, route }) {
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user