From 8e86872aa7cc6c7369f04a2bc8b1365369f5c84b11c53d320c7e68a2d6e195e1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 18:59:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/config/ShineSignatureConstants.java | 3 + .../java/shine/db/DatabaseInitializer.java | 6 + .../shine/db/dao/ChannelReadStateDAO.java | 281 ++++ .../main/resources/postgres/migration_v21.sql | 25 + .../src/main/resources/postgres/schema_v1.sql | 22 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../blockchain/Net_AddBlock_Handler.java | 8 +- .../BlockchainWriter.java | 42 +- .../channels/ChannelsReadSupport.java | 36 +- .../channels/Net_GetMessageLikes_Handler.java | 13 +- .../Net_ListSubscriptionsFeed_Handler.java | 9 +- .../Net_SetChannelReadState_Handler.java | 137 ++ .../channels/UserCountersSupport.java | 26 +- .../Net_ListSubscriptionsFeed_Response.java | 4 + .../Net_SetChannelReadState_Request.java | 34 + .../Net_SetChannelReadState_Response.java | 34 + .../Net_UpsertUserSetting_Handler.java | 4 - VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 83 +- docs/API/07_Channels_Feature_Runbook.md | 34 +- docs/API/09_Operations_Index.md | 1 + docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md | 1352 +++++++++++++++++ shine-UI/js/pages/channel-view.js | 101 +- shine-UI/js/pages/profile-view.js | 76 +- shine-UI/js/pages/user-profile-list-view.js | 135 +- shine-UI/js/pages/user-profile-view.js | 76 +- shine-UI/js/services/auth-service.js | 54 +- shine-UI/styles/features/channel.css | 3 + shine-UI/styles/features/profile.css | 162 ++ 29 files changed, 2538 insertions(+), 231 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java create mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java index a8cb5d08..fd730bba 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java @@ -17,6 +17,9 @@ public final class ShineSignatureConstants { /** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */ public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:"; + /** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */ + public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:"; + /** TAG в HeaderBody (genesis). ASCII "SHiNe". */ public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe"; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index eb9bb3f6..021e9c8b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -37,6 +37,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_18 = 18; public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_20 = 20; + public static final int SCHEMA_VERSION_21 = 21; 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"; @@ -57,6 +58,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql"; public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; + public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; private DatabaseInitializer() {} @@ -206,6 +208,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE); currentVersion = SCHEMA_VERSION_20; } + if (currentVersion < SCHEMA_VERSION_21) { + runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE); + currentVersion = SCHEMA_VERSION_21; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java new file mode 100644 index 00000000..b5d0825c --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java @@ -0,0 +1,281 @@ +package shine.db.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Server projection for per-user channel read/unread state. + * + * Important rules: + * - rows are created only by a signed client SetChannelReadState; + * - channel publications increment unread_count only for rows that already exist; + * - read_count is monotonic and unread_count is reduced by the accepted read delta; + * - unread_count is never part of the client signature and is server-owned. + */ +public final class ChannelReadStateDAO { + private static volatile ChannelReadStateDAO instance; + + private ChannelReadStateDAO() {} + + public static ChannelReadStateDAO getInstance() { + if (instance == null) { + synchronized (ChannelReadStateDAO.class) { + if (instance == null) instance = new ChannelReadStateDAO(); + } + } + return instance; + } + + public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs, + String clientKey, String readSignature) {} + + public record UpsertResult(boolean applied, long readCount, long unreadCount) {} + + public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature + FROM channel_read_state + WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new ReadState( + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count")), + rs.getLong("read_updated_at_ms"), + rs.getString("client_key"), + rs.getString("read_signature")); + } + } + } + + public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + ReadState state = get(c, viewerLogin, ownerBchName, channelName); + return state == null ? 0L : state.unreadCount(); + } + + public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException { + String sql = """ + SELECT COALESCE(SUM(crs.unread_count),0) + FROM channel_read_state crs + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=30 + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L; + } + } + } + + /** + * Validates that the requested public channel exists and that viewer currently follows it. + */ + public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName, + int followRelType) throws SQLException { + String sql = """ + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(?) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=? + AND cns.slug=? + AND cns.channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setInt(2, followRelType); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException { + String sql = """ + SELECT slug + FROM channel_names_state + WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("slug") : null; + } + } + } + + public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT owner_login + FROM channel_names_state + WHERE owner_bch_name=? AND slug=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setString(2, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("owner_login") : null; + } + } + } + + /** + * Inserts the first client baseline with unread=0, or advances an existing read watermark. + * Older timestamps and backwards read_count values are ignored. + */ + public UpsertResult upsertSignedReadIfNewer(Connection c, + String viewerLogin, + String ownerLogin, + String ownerBchName, + String channelName, + long readCount, + long readUpdatedAtMs, + String clientKey, + String readSignature, + long nowMs) throws SQLException { + String sql = """ + INSERT INTO channel_read_state ( + viewer_login, owner_login, owner_bch_name, channel_name, + read_count, unread_count, + read_updated_at_ms, client_key, read_signature, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + ON CONFLICT (viewer_login, owner_bch_name, channel_name) + DO UPDATE SET + owner_login = EXCLUDED.owner_login, + unread_count = GREATEST( + 0, + channel_read_state.unread_count - + GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count) + ), + read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count), + read_updated_at_ms = EXCLUDED.read_updated_at_ms, + client_key = EXCLUDED.client_key, + read_signature = EXCLUDED.read_signature, + updated_at_ms = EXCLUDED.updated_at_ms + WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms + AND EXCLUDED.read_count >= channel_read_state.read_count + RETURNING read_count, unread_count + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + ps.setLong(5, Math.max(0L, readCount)); + ps.setLong(6, readUpdatedAtMs); + ps.setString(7, clientKey); + ps.setString(8, readSignature); + ps.setLong(9, nowMs); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new UpsertResult(true, + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count"))); + } + } + } + ReadState current = get(c, viewerLogin, ownerBchName, channelName); + return new UpsertResult(false, + current == null ? 0L : current.readCount(), + current == null ? 0L : current.unreadCount()); + } + + /** + * Increments existing rows for current subscribers of one public channel. + * Missing channel_read_state rows are deliberately NOT created. + * Returns affected viewer logins for post-commit counter pushes. + */ + public List incrementUnreadForSubscribers(Connection c, + String ownerBchName, + String channelName, + int followRelType, + long nowMs) throws SQLException { + String sql = """ + UPDATE channel_read_state crs + SET unread_count = crs.unread_count + 1, + updated_at_ms = ? + WHERE crs.owner_bch_name = ? + AND crs.channel_name = ? + AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + RETURNING viewer_login + """; + List affected = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, nowMs); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + ps.setInt(4, followRelType); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) affected.add(rs.getString("viewer_login")); + } + } + return affected; + } + + /** Delete the projection row when a channel FOLLOW is explicitly removed. */ + public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName, + int rootBlockNumber) throws SQLException { + String sql = """ + DELETE FROM channel_read_state crs + USING channel_names_state cns + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND crs.owner_bch_name=? + AND cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_root_block_number=? + AND cns.channel_type_code=1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setInt(3, rootBlockNumber); + return ps.executeUpdate() > 0; + } + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql new file mode 100644 index 00000000..d5af4b69 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql @@ -0,0 +1,25 @@ +-- Channel unread/read projection. +-- The row is created only by a signed SetChannelReadState from the client. +-- New channel publications only increment existing rows. + +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + +UPDATE db_schema_version SET schema_version = 21 WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 08330371..52081438 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -425,6 +425,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login CREATE INDEX IF NOT EXISTS idx_user_settings_type_key ON user_settings(setting_type, setting_key); +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + CREATE TABLE IF NOT EXISTS ip_geo_cache ( ip TEXT PRIMARY KEY, geo TEXT, @@ -2009,7 +2029,7 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state ( ); INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,21,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; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 3462dfc9..5dbf6e86 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -82,6 +82,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_H import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; @@ -91,6 +92,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalD 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.channels.entyties.Net_GetUserCounters_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler; @@ -217,6 +219,7 @@ public final class JsonHandlerRegistry { Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()), + Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()), @@ -310,6 +313,7 @@ public final class JsonHandlerRegistry { Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), Map.entry("GetUserCounters", Net_GetUserCounters_Request.class), + Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index da343d11..0c10212e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.WireCodes; import server.sync.AddBlockSyncService; import shine.db.channels.ChannelNameRules; @@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry; import utils.blockchain.BlockchainNameUtil; import java.util.Arrays; +import java.util.List; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -586,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); - dbWriter.appendBlockAndState( + List counterChangedLogins = dbWriter.appendBlockAndState( blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); + for (String changedLogin : counterChangedLogins) { + UserCountersSupport.pushChanged(changedLogin); + } + if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 72a730d1..936b3c1d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -4,6 +4,8 @@ import blockchain.BchBlockEntry; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; +import shine.db.dao.ChannelReadStateDAO; +import shine.db.MsgSubType; import shine.db.dao.UserParamsDAO; import shine.db.dao.UserNotificationsStateDAO; import shine.db.entities.BlockchainStateEntry; @@ -19,6 +21,8 @@ import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; /** * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. @@ -44,6 +48,7 @@ public final class BlockchainWriter { private final ChannelNameStateDAO channelNameStateDAO; private final UserParamsDAO userParamsDAO; private final UserNotificationsStateDAO userNotificationsStateDAO; + private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance(); private final FileStoreUtil fs = FileStoreUtil.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, @@ -58,7 +63,7 @@ public final class BlockchainWriter { this.userNotificationsStateDAO = userNotificationsStateDAO; } - public void appendBlockAndState(String blockchainName, + public List appendBlockAndState(String blockchainName, BchBlockEntry block, BlockchainStateEntry st, BlockEntry be, @@ -75,6 +80,7 @@ public final class BlockchainWriter { prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); boolean committed = false; + List counterChangedLogins = new ArrayList<>(); try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { @@ -108,6 +114,29 @@ public final class BlockchainWriter { userNotificationsStateDAO.upsert(c, notificationEntry); } + // Channel read/unread projection is updated in the same SQL transaction as the block. + int msgType = block.type & 0xFFFF; + int msgSubType = block.subType & 0xFFFF; + Integer lineCode = be.getLineCode(); + if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) { + String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode); + if (channelName != null && !channelName.isBlank()) { + counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers( + c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs)); + } + } + + // Explicit UNFOLLOW removes the projection row. FOLLOW never creates it. + if (msgType == 3 + && msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF) + && be.getToBchName() != null && !be.getToBchName().isBlank() + && be.getToBlockNumber() != null && be.getToBlockNumber() > 0) { + if (channelReadStateDAO.deleteForUnfollowTarget( + c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) { + counterChangedLogins.add(be.getLogin()); + } + } + c.commit(); committed = true; } catch (Exception e) { @@ -142,6 +171,17 @@ public final class BlockchainWriter { // 4) После успешной подмены — чистим временные артефакты. cleanupWriteArtifactsBestEffort(blockchainName); + return counterChangedLogins; + } + + private static boolean isPublicChannelPublication(int msgType, int msgSubType) { + if (msgType != 1) return false; + return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF); } private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index b0cfce4d..bd3ac25a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -144,38 +144,12 @@ final class ChannelsReadSupport { } } - static String userSettingsChannelKey(String ownerBch, String channelName) { - String bch = ownerBch == null ? "" : ownerBch.trim(); - String name = channelName == null ? "" : channelName.trim(); - return bch + "/" + name; - } - static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException { - if (viewerLogin == null || viewerLogin.isBlank()) return 0; - String key = userSettingsChannelKey(ownerBch, channelName); - String sql = """ - SELECT value_num - FROM user_settings - WHERE LOWER(login) = LOWER(?) - AND setting_type = ? - AND setting_key = ? - LIMIT 1 - """; - long lastSeen = messagesCount; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, viewerLogin); - ps.setInt(2, 1); - ps.setString(3, key); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - long value = rs.getLong("value_num"); - if (!rs.wasNull()) lastSeen = value; - } - } - } - if (lastSeen < 0) lastSeen = 0; - if (lastSeen > messagesCount) return 0; - return Math.max(0, messagesCount - (int) lastSeen); + if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() + || channelName == null || channelName.isBlank()) return 0; + long unread = shine.db.dao.ChannelReadStateDAO.getInstance() + .getUnreadCount(c, viewerLogin, ownerBch, channelName); + return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread); } static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java index beec7175..3cd3fa8d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java @@ -20,7 +20,6 @@ import java.util.List; public class Net_GetMessageLikes_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); - private static final int HARD_LIMIT = 1000; @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { @@ -42,7 +41,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); } - int requested = req.getLimit() == null ? HARD_LIMIT : Math.max(1, Math.min(HARD_LIMIT, req.getLimit())); try (Connection c = DbController.getInstance().getConnection()) { String sql = """ SELECT rs.from_login, @@ -59,7 +57,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { AND rs.to_block_number = ? AND rs.to_block_hash = ? ORDER BY LOWER(rs.from_login) - LIMIT ? """; List shining = new ArrayList<>(); @@ -69,16 +66,8 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { ps.setString(1, message.getBlockchainName().trim()); ps.setInt(2, message.getBlockNumber()); ps.setBytes(3, blockHash); - ps.setInt(4, requested + 1); try (ResultSet rs = ps.executeQuery()) { - int accepted = 0; - boolean truncated = false; while (rs.next()) { - if (accepted >= requested) { - truncated = true; - break; - } - accepted++; Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); user.setLogin(rs.getString("from_login")); user.setFirstName(rs.getString("first_name")); @@ -100,7 +89,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { resp.setOfficial(official); resp.setOthers(others); resp.setTotal(shining.size() + official.size() + others.size()); - resp.setTruncated(truncated); + resp.setTruncated(false); return resp; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index faebbb6c..394215c4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; import shine.db.DbController; +import shine.db.dao.ChannelReadStateDAO; import java.sql.Connection; import java.sql.PreparedStatement; @@ -83,9 +84,11 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { row.setChannel(channelRef); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin); - row.setUnreadCount(ownChannel - ? 0 - : ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount())); + ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null + ? null + : ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName); + row.setReadStateInitialized(readState != null); + row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount())); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); if (lastPost != null) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java new file mode 100644 index 00000000..2c960e76 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java @@ -0,0 +1,137 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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_SetChannelReadState_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.MsgSubType; +import shine.db.dao.ChannelReadStateDAO; +import utils.config.ShineSignatureConstants; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; + +/** Accepts the signed client read watermark for one followed public channel. */ +public final class Net_SetChannelReadState_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class); + private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getLogin() == null || req.getLogin().isBlank() + || req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank() + || req.getChannel_name() == null || req.getChannel_name().isBlank() + || req.getRead_count() == null || req.getRead_count() < 0 + || req.getTime_ms() == null || req.getTime_ms() <= 0 + || req.getClient_key() == null || req.getClient_key().isBlank() + || req.getSignature() == null || req.getSignature().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", "Некорректные поля ChannelReadState"); + } + + String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim(); + String login = req.getLogin().trim(); + String ownerBch = req.getOwner_bch_name().trim(); + String channelName = req.getChannel_name().trim(); + long readCount = req.getRead_count(); + long timeMs = req.getTime_ms(); + String clientKeyB64 = req.getClient_key().trim(); + String signatureB64 = req.getSignature().trim(); + + if (!authenticatedLogin.equalsIgnoreCase(login)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю"); + } + long nowMs = System.currentTimeMillis(); + if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "BAD_TIME", "Некорректное время подписи"); + } + + try { + byte[] pubKey32; + byte[] sig64; + try { + pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", "client_key/signature должны быть Base64"); + } + + String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null + ? "" : ctx.getCurrentUser().getClientKey()).trim(); + if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) { + return NetExceptionResponseFactory.error(req, 403, + "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); + } + + String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX + + escapePart(login) + '|' + + escapePart(ownerBch) + '|' + + escapePart(channelName) + '|' + + timeMs + '|' + + readCount; + if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку"); + } + + ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance(); + ChannelReadStateDAO.UpsertResult result; + try (Connection c = DbController.getInstance().getConnection()) { + if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName, + MsgSubType.CONNECTION_FOLLOW)) { + return NetExceptionResponseFactory.error(req, 409, + "CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал"); + } + String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName); + if (ownerLogin == null || ownerLogin.isBlank()) { + return NetExceptionResponseFactory.error(req, 404, + "CHANNEL_NOT_FOUND", "Канал не найден"); + } + result = dao.upsertSignedReadIfNewer(c, + authenticatedLogin, ownerLogin, ownerBch, channelName, + readCount, timeMs, clientKeyB64, signatureB64, nowMs); + } + + Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(authenticatedLogin); + resp.setOwner_bch_name(ownerBch); + resp.setChannel_name(channelName); + resp.setRead_count(result.readCount()); + resp.setUnread_count(result.unreadCount()); + resp.setTime_ms(timeMs); + resp.setApplied(result.applied()); + if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin); + return resp; + } catch (Exception e) { + log.error("SetChannelReadState failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage( + "Внутренняя ошибка SetChannelReadState", e)); + } + } + + private static String escapePart(String value) { + String s = String.valueOf(value == null ? "" : value); + return s.replace("\\", "\\\\").replace("|", "\\|"); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java index c0e32f61..c01830db 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java @@ -6,8 +6,8 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.push.WsEventSender; import shine.db.DbController; -import shine.db.MsgSubType; import shine.db.dao.UserNotificationSeenStateDAO; +import shine.db.dao.ChannelReadStateDAO; import shine.db.dao.UserNotificationsStateDAO; import java.sql.Connection; @@ -89,28 +89,6 @@ public final class UserCountersSupport { } private static long countChannelsUnread(Connection c, String login) throws Exception { - String sql = """ - SELECT cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number - FROM connections_state cs - WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=? - """; - long total = 0L; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setInt(2, MsgSubType.CONNECTION_FOLLOW); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String ownerBch = rs.getString("to_bch_name"); - int rootNumber = rs.getInt("root_number"); - if (ownerBch == null || ownerBch.isBlank()) continue; - ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, rootNumber); - if (meta == null || meta.channelName == null || meta.channelName.isBlank()) continue; - if (meta.channelTypeCode == 0 || "stories".equalsIgnoreCase(meta.channelName)) continue; - int messages = ChannelsReadSupport.countPosts(c, ownerBch, rootNumber); - total += Math.max(0, ChannelsReadSupport.countUnreadMessages(c, login, ownerBch, meta.channelName, messages)); - } - } - } - return total; + return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 2c1651ac..1f163dea 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -27,6 +27,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { private ChannelRef channel; private int messagesCount; private int unreadCount; + private boolean readStateInitialized; private LastMessage lastMessage; public ChannelRef getChannel() { return channel; } @@ -38,6 +39,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public int getUnreadCount() { return unreadCount; } public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } + public boolean isReadStateInitialized() { return readStateInitialized; } + public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; } + public LastMessage getLastMessage() { return lastMessage; } public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java new file mode 100644 index 00000000..35c52496 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SetChannelReadState_Request extends Net_Request { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long time_ms; + private String client_key; + private String signature; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java new file mode 100644 index 00000000..9db51d78 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_SetChannelReadState_Response extends Net_Response { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long unread_count; + private Long time_ms; + private Boolean applied; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getUnread_count() { return unread_count; } + public void setUnread_count(Long unread_count) { this.unread_count = unread_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public Boolean getApplied() { return applied; } + public void setApplied(Boolean applied) { this.applied = applied; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java index eeaf5a3e..375dacfd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java @@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response; -import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; @@ -101,9 +100,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { resp.setSetting_type(settingType); resp.setSetting_key(settingKey); resp.setTime_ms(timeMs); - if (settingType == 1) { - UserCountersSupport.pushChanged(login); - } return resp; } } catch (SQLException e) { diff --git a/VERSION.properties b/VERSION.properties index d30e91ce..adbdaefc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.5 -server.version=1.10.2 +client.version=1.12.6 +server.version=1.10.3 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 944a3b06..99172efd 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -21,9 +21,11 @@ 6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -7. `ListGroupChats200` — отдает список групповых чатов типа `200`. +7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик. -8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +8. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > @@ -110,6 +112,8 @@ "channelRoot": { "blockNumber": 456, "blockHash": "..." } }, "messagesCount": 90, + "unreadCount": 0, + "readStateInitialized": false, "lastMessage": { "messageRef": { "blockNumber": 1002, "blockHash": "..." }, "text": "актуальный текст", @@ -274,7 +278,7 @@ Возвращает пользователей, которые поставили лайк конкретному сообщению канала. - `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. -- `limit` ограничивается сервером сверху значением `1000`. +- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков. - Пользователи группируются по состоянию профиля: - `shining` — `account_role=primary` и `shine_status=shining`; - `official` — `account_role=primary`, но без `shine_status=shining`; @@ -290,8 +294,7 @@ "blockchainName": "bob-001", "blockNumber": 140, "blockHash": "..." - }, - "limit": 1000 + } } } ``` @@ -322,6 +325,8 @@ - `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока. - `internal_error` — внутренняя ошибка чтения. +`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией. + --- ## 5) GetPersonalDiary @@ -379,7 +384,69 @@ --- -## 7) ListGroupChats200 +## 7) SetChannelReadState + +Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан. + +- Требует авторизованное WebSocket-соединение. +- `login` должен совпадать с текущим авторизованным пользователем. +- Подпись строится client key пользователя по строке: + +```text +SHiNe/ChannelReadState:|||| +``` + +### Request +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" + } +} +``` + +### Response (success) +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "status": 200, + "ok": true, + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "unread_count": 0, + "time_ms": 1760000000000, + "applied": true + } +} +``` + +### Ошибки +- `NOT_AUTHENTICATED` — нет авторизованной сессии. +- `BAD_FIELDS` — не переданы обязательные поля. +- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией. +- `BAD_TIME` — время подписи слишком далеко в будущем. +- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64. +- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя. +- `INVALID_SIGNATURE` — подпись watermark не прошла проверку. +- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал. +- `CHANNEL_NOT_FOUND` — канал не найден. +- `INTERNAL_ERROR` — внутренняя ошибка записи. + +--- + +## 8) ListGroupChats200 ### Request ```json @@ -419,7 +486,7 @@ --- -## 8) GetGroupDialog +## 9) GetGroupDialog ### Request ```json @@ -474,6 +541,8 @@ - `message_not_found` - `limit_too_large` - `channel_name_already_exists` +- `CHANNEL_NOT_FOLLOWED` +- `CHANNEL_NOT_FOUND` - `internal_error` --- diff --git a/docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md index 9df49d25..235bf663 100644 --- a/docs/API/07_Channels_Feature_Runbook.md +++ b/docs/API/07_Channels_Feature_Runbook.md @@ -9,6 +9,7 @@ - `GetChannelMessages` — сообщения конкретного канала. - `GetMessageThread` — дерево обсуждения для сообщения. - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. + - `SetChannelReadState` — подписанный watermark чтения канала. 2. **UI вкладки Каналы**: - при открытии пытается загрузить реальный feed с сервера; @@ -35,13 +36,15 @@ 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 3. Для первого `messages[0]` вызвать `GetMessageThread`. 4. Для первого `messages[0]` вызвать `GetMessageLikes`. +5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`. ### Ошибки 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`. 4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. -5. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. +5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`. +6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. --- @@ -104,8 +107,24 @@ "blockchainName": "TestUser1-001", "blockNumber": 123, "blockHash": "" - }, - "limit": 1000 + } + } +} +``` + +## 3.5 SetChannelReadState +```json +{ + "op": "SetChannelReadState", + "requestId": "debug-read-state-1", + "payload": { + "login": "TestUser1", + "owner_bch_name": "TestUser2-001", + "channel_name": "news", + "read_count": 25, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" } } ``` @@ -120,6 +139,8 @@ - у каждой записи есть: - `channel.channelRoot.blockNumber`, - `messagesCount`, + - `unreadCount`, + - `readStateInitialized`, - `lastMessage` (может быть null, если сообщений нет). ### GetChannelMessages @@ -137,7 +158,12 @@ ### GetMessageLikes - `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. - у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. -- `payload.truncated=true` означает, что сервер обрезал список по `limit`. +- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации. + +### SetChannelReadState +- `payload.read_count` — сохраненная позиция чтения. +- `payload.unread_count` — пересчитанный unread для канала. +- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция. ### Важно по совместимости - `rawBlockB64` добавлен только в `GetMessageThread`. diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index abaa33da..02ff9351 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -49,6 +49,7 @@ | `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | +| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя | diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md new file mode 100644 index 00000000..52a0afd2 --- /dev/null +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md @@ -0,0 +1,1352 @@ +# SHiNE Archive Protocol v1.0 + +## Полная русская спецификация серверной фиксации больших блоков, Arweave, Solana User PDA и навигации по предыдущим записям пользователя + +**Статус:** рабочая спецификация v1.0 +**Формат:** бинарный +**Magic:** `SHINE-ARCHIVE` +**Версия:** два отдельных байта `major` / `minor` + +--- + +# 1. Назначение + +Протокол предназначен для периодической долговременной фиксации новых SHiNE-блоков. + +Стартовая схема: + +```text +Основной SHiNE-сервер + | + | существующая межсерверная синхронизация + v +Raspberry / архивный SHiNE-сервер + | + +--> видит все новые локальные SHiNE-блоки + +--> фиксирует стабильную дельту + +--> собирает большой SHINE-ARCHIVE блок + +--> загружает его в Arweave + +--> ждёт закрепления Arweave-транзакции + +--> обычным update своего User PDA записывает новый archive head +``` + +Существующую межсерверную синхронизацию в v1 менять не требуется. + +Архивный сервер не создаёт и не переподписывает пользовательские сообщения. Он берёт уже существующие пользовательские записи и добавляет только серверную навигационную метаинформацию большого блока. + +--- + +# 2. Основные принципы + +1. Публикуется только новая дельта. +2. Дельта замораживается до начала загрузки. +3. Новые данные, пришедшие во время публикации, попадают в следующий большой блок. +4. После загрузки сервер ждёт закрепления Arweave-транзакции. +5. Только после закрепления Arweave сервер обновляет свой User PDA в Solana. +6. Только после `finalized` в Solana локальные курсоры считаются окончательно сдвинутыми. +7. Большой блок имеет собственный SHA-256 и подпись составившего его SHiNE-аккаунта. +8. Каждый большой блок содержит список предыдущих больших блоков с номером, hash и Arweave TX ID. +9. В v1 в начало каждого нового большого блока записывается полный список всех известных предыдущих больших блоков данной ветки. +10. Для каждого пользователя сервер строит обратную цепочку не на одну предыдущую запись, а на **предыдущий большой блок, где были записи этого пользователя, плюс список всех его записей внутри того блока**. + +--- + +# 3. Ключи архивного сервера + +Для первой реализации серверу требуются: + +- `root private key` SHiNE-аккаунта; +- `client private key` SHiNE-аккаунта; +- Arweave JWK/private wallet key. + +`blockchain private key` для самой архивной публикации не требуется: пользовательские записи уже существуют и уже подписаны. + +В v1 большой блок может подписываться root key того SHiNE-аккаунта, который его сформировал. + +--- + +# 4. Локальное состояние дельты + +Нужно различать: + +1. уже окончательно опубликованное состояние; +2. текущую замороженную дельту, которая ещё проходит Arweave/Solana. + +Рекомендуются три таблицы. + +## 4.1. `archive_chain_cursor` + +```sql +CREATE TABLE archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + last_archived_block_number BIGINT NOT NULL, + last_archived_block_hash BYTEA NOT NULL, + updated_at_ms BIGINT NOT NULL +); +``` + +Она хранит точку, до которой конкретная локальная blockchain уже окончательно вошла в опубликованный и зафиксированный archive head. + +## 4.2. `archive_publish_job` + +```sql +CREATE TABLE archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + archive_number BIGINT NOT NULL, + status TEXT NOT NULL, + parent_arweave_tx_id BYTEA, + parent_archive_hash BYTEA, + created_at_ms BIGINT NOT NULL, + local_archive_path TEXT, + archive_hash BYTEA, + arweave_tx_id BYTEA, + arweave_confirmations INTEGER, + solana_signature TEXT, + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); +``` + +Рекомендуемые состояния: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +FAILED +``` + +## 4.3. `archive_publish_job_chain` + +```sql +CREATE TABLE archive_publish_job_chain ( + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + from_block_number BIGINT NOT NULL, + to_block_number BIGINT NOT NULL, + previous_block_hash BYTEA NOT NULL, + last_block_hash BYTEA NOT NULL, + PRIMARY KEY (job_id, blockchain_name) +); +``` + +Сырые блоки в staging-таблицы копировать не нужно. В job фиксируются только точные диапазоны и крайние hash. + +--- + +# 5. Заморозка дельты + +Пример: + +```text +локально сейчас: +Alice = 180 +Bob = 94 + +уже опубликовано: +Alice = 160 +Bob = 90 +``` + +Новый job фиксирует: + +```text +Alice: 161..180 +Bob: 91..94 +``` + +Если во время Arweave upload локальное состояние стало: + +```text +Alice = 185 +Bob = 97 +``` + +текущий job НЕ меняется. + +Следующая публикация начнётся с: + +```text +Alice: 181..185 +Bob: 95..97 +``` + +--- + +# 6. Общая структура большого блока + +Большой опубликованный файл одновременно является: + +- архивной дельтой; +- индексом предыдущих больших блоков; +- контейнером пользовательских записей; +- навигационным узлом для истории каждого пользователя; +- подписанным объектом того SHiNE-аккаунта, который его сформировал. + +Структура: + +```text ++------------------------------------------------+ +| PREFIX / HEADER | +| magic = SHINE-ARCHIVE | +| version major/minor | +| размеры/счётчики | +| номер большого блока | +| время | +| LOGIN составителя | +| режим таблицы предыдущих блоков | +| число предыдущих блоков | ++------------------------------------------------+ +| PREVIOUS BLOCK REFERENCES TABLE | +| | +| block_number + block_hash + arweave_tx_id | +| block_number + block_hash + arweave_tx_id | +| ... | ++------------------------------------------------+ +| USER RECORDS / SERVER NAVIGATION METADATA | +| ... | ++------------------------------------------------+ +| FOOTER | +| LOGIN закрывшего блок | +| block_hash | +| signature | ++------------------------------------------------+ +``` + +Логин в header и footer должен быть одинаковым. + +Дублирование логина намеренное: + +- header позволяет сразу при скачивании начала файла увидеть, кто составил блок; +- footer делает закрытие блока человекочитаемым рядом с hash и подписью. + +--- + +# 7. Magic и версия + +Magic: + +```text +SHINE-ARCHIVE +``` + +Это ровно 13 ASCII-байт: + +```text +53 48 49 4E 45 2D 41 52 43 48 49 56 45 +``` + +Версия: + +```text +u8 version_major +u8 version_minor +``` + +Для этой спецификации: + +```text +1 +0 +``` + +то есть `v1.0`. + +--- + +# 8. Порядок байтов + +Все fixed-size integer-поля v1 хранятся в: + +```text +big-endian +``` + +--- + +# 9. Header большого блока v1.0 + +Header должен быть устроен так, чтобы клиент мог сначала скачать только небольшой префикс файла, узнать размер всей начальной индексной области и затем докачать только её. + +Формат: + +```text +bytes[13] magic = "SHINE-ARCHIVE" + +u8 version_major +u8 version_minor + +u32 header_size +u32 block_number +u64 created_at_ms + +u8 creator_login_length +bytes[N] creator_login UTF-8 + +u8 references_mode +u32 references_count +u16 reference_entry_size + +u32 records_count +``` + +## 9.1. `header_size` + +`header_size` — абсолютный byte offset от начала файла до первого байта области пользовательских записей. + +То есть включает: + +```text +header prefix ++ creator_login ++ всю таблицу PreviousBlockReference +``` + +Клиент может: + +1. скачать первые условные 64–128 байт; +2. прочитать `header_size`; +3. докачать `0 .. header_size-1`; +4. уже иметь все адреса предыдущих больших блоков; +5. после этого скачивать только нужные user records. + +## 9.2. `block_number` + +```text +u32 +``` + +Номер большого итогового блока данной ветки. + +Диапазон: + +```text +0 .. 4 294 967 295 +``` + +Для предполагаемой частоты закрытия это более чем достаточный запас. + +## 9.3. `created_at_ms` + +```text +u64 +``` + +Unix Epoch UTC в миллисекундах. + +Это время фиксации snapshot текущего большого блока. + +## 9.4. `creator_login` + +Логин SHiNE-пользователя/сервера, который сформировал этот большой блок. + +Пример: + +```text +raspberry-archive +``` + +Это поле находится именно в header, поэтому клиент видит автора уже после скачивания начала блока. + +`creator_login` входит в hash большого блока и тем самым криптографически привязан к его подписи. + +## 9.5. `references_mode` + +```text +u8 +``` + +Зарезервированные значения: + +```text +0 = FULL_HISTORY +1 = PARTIAL_HISTORY +``` + +Для v1 publisher MUST использовать: + +```text +FULL_HISTORY +``` + +То есть каждый новый большой блок содержит ссылки на все известные предыдущие большие блоки своей ветки. + +`PARTIAL_HISTORY` резервируется на будущее, если когда-нибудь полный список станет слишком большим. + +## 9.6. `references_count` + +```text +u32 +``` + +Количество элементов в таблице предыдущих больших блоков. + +## 9.7. `reference_entry_size` + +```text +u16 +``` + +Для v1: + +```text +68 +``` + +Поле оставлено явно, чтобы будущая minor/major версия могла расширить `PreviousBlockReference`, а клиент всё ещё мог корректно пропускать неизвестные дополнительные байты. + +## 9.8. `records_count` + +```text +u32 +``` + +Количество пользовательских record-контейнеров внутри большого блока. + +--- + +# 10. Таблица всех предыдущих больших блоков + +Сразу после header идёт: + +```text +PreviousBlockReference[references_count] +``` + +В v1 это **полная история предыдущих больших блоков данной ветки**. + +При одном закрытии в сутки даже через год это около 365 элементов, поэтому простота полного списка важнее преждевременной оптимизации. + +--- + +# 11. `PreviousBlockReference v1` + +Формат одного элемента: + +```text +u32 block_number +bytes[32] block_hash +bytes[32] arweave_tx_id +``` + +Размер: + +```text +4 + 32 + 32 = 68 байт +``` + +Смысл: + +```text +block_number +-> логический номер большого блока + +block_hash +-> SHA-256 / канонический hash точного содержимого большого блока + +arweave_tx_id +-> raw 32-byte TX ID, по которому этот большой блок можно скачать из Arweave +``` + +То есть: + +```text +HASH = идентичность содержимого +TXID = место хранения +``` + +Оба поля обязательны. + +Внешнее строковое Base64URL-представление TX ID внутрь бинарного файла не записывается. + +Таблица v1 SHOULD быть отсортирована детерминированно по `block_number`, затем по `block_hash`. + +--- + +# 12. Почему в v1 записываются все предыдущие блоки + +Если закрывать примерно один большой блок в сутки: + +```text +365 × 68 ≈ 24.8 KB в год +``` + +Даже через много лет это остаётся небольшим overhead по сравнению с полезными данными. + +Главное преимущество: любой новый большой блок сразу является индексом всей предыдущей ветки. + +Клиент не обязан идти: + +```text +365 -> 364 -> 363 -> ... -> 95 +``` + +Чтобы получить адрес блока 95. + +Он получает его из начала текущего блока. + +--- + +# 13. Пользовательская обратная навигация + +Навигация пользователя строится не на одну предыдущую запись. + +Правило v1: + +> Для каждого пользователя сервер находит **предыдущий большой блок**, в котором существовала хотя бы одна запись этого пользователя, и сохраняет ссылку на этот большой блок вместе со списком `offset + size` **всех записей этого пользователя в том предыдущем большом блоке**. + +Например: + +```text +Большой block 100: +Alice сегодня имеет 2 записи. + +Предыдущий большой блок, где Alice присутствовала: +block 95. + +В block 95 у Alice было 3 записи: + offset 8123 / size 417 + offset 9001 / size 351 + offset 14020 / size 692 +``` + +Тогда server-generated ссылка Alice из block 100 содержит все три диапазона. + +--- + +# 14. `PreviousUserRecordsRef v1` + +Формат: + +```text +u32 previous_block_ref +u32 previous_records_count + +repeat previous_records_count times: + u32 previous_record_offset + u32 previous_record_size +``` + +Минимальный размер: + +```text +8 байт +``` + +Плюс: + +```text +8 байт на каждую предыдущую запись пользователя +``` + +Пример для 3 сообщений: + +```text +previous_block_ref = 94 +previous_records_count = 3 + +8123 417 +9001 351 +14020 692 +``` + +Если `PreviousBlockReference[94]` описывает big block 95, клиент получает: + +```text +block number = 95 +block hash = ... +Arweave TXID = ... +``` + +и может HTTP Range-запросами забрать только три нужных диапазона. + +--- + +# 15. `previous_block_ref` + +`previous_block_ref` — индекс в таблице `PreviousBlockReference` **текущего большого блока**. + +То есть пользовательская ссылка не повторяет 32-byte TX ID и 32-byte hash. + +Она хранит только компактный индекс. + +Специальное значение: + +```text +0xFFFFFFFF +``` + +означает: + +```text +у пользователя ещё не было записей в предыдущих больших блоках +``` + +В этом случае: + +```text +previous_records_count = 0 +``` + +--- + +# 16. Смещение и размер записи + +`previous_record_offset`: + +```text +u32 +``` + +Абсолютный offset от первого байта соответствующего большого `SHINE-ARCHIVE` файла до первого байта нужной архивированной user-record. + +`previous_record_size`: + +```text +u32 +``` + +Полный размер этой архивированной user-record в байтах. + +Таким образом, зная: + +```text +TX ID +block hash +offset +size +``` + +клиент может скачать ровно нужную пользовательскую запись. + +--- + +# 17. Как сервер добавляет пользовательскую ссылку + +`PreviousUserRecordsRef` создаёт **сервер, который формирует большой блок**, а не пользовательский клиент. + +Оригинальные подписанные пользователем bytes изменять нельзя. + +Поэтому server navigation metadata хранится как часть контейнера большого блока вокруг/после оригинальных user-record bytes и покрывается: + +- `block_hash` большого блока; +- подписью сервера/пользователя, который закрыл большой блок. + +Для каждого пользователя в текущем большом блоке достаточно одного `PreviousUserRecordsRef`, связанного с последней записью этого пользователя в текущем блоке. + +Если у пользователя сегодня в текущем большом блоке две записи, сервер после их сериализации добавляет один navigation tail, который указывает на предыдущий большой блок пользователя и перечисляет все его записи там. + +При чтении предыдущего большого блока та же схема даёт следующий переход назад. + +Получается: + +```text +Alice in block 100 + | + | PreviousUserRecordsRef + | -> block 95 + | -> offsets of ALL Alice records in block 95 + v +Alice records in block 95 + | + | server navigation metadata из block 95 + | -> block 81 + | -> offsets of ALL Alice records in block 81 + v +... +``` + +--- + +# 18. Рекомендуемый контейнер архивированной пользовательской записи + +Чтобы сервер мог добавлять свою навигационную метаинформацию, не изменяя исходную пользовательскую подпись, v1 SHOULD использовать серверный envelope. + +Рекомендуемая модель: + +```text +u32 archived_record_size +u32 raw_user_record_size +bytes[N] raw_user_record +u8 navigation_flags +[optional server navigation data] +``` + +Где: + +```text +navigation_flags bit 0 = PreviousUserRecordsRef присутствует +``` + +У большинства записей: + +```text +navigation_flags = 0 +``` + +У последней записи конкретного пользователя в текущем большом блоке: + +```text +navigation_flags bit0 = 1 +``` + +и далее сериализован `PreviousUserRecordsRef`. + +`archived_record_size` позволяет Range-reader получить и полностью декодировать конкретный archived record. + +Исходный `raw_user_record` остаётся байт-в-байт неизменным. + +--- + +# 19. Пример пользовательской цепочки + +```text +BIG BLOCK 100 + +Alice record A +Alice record B + PreviousUserRecordsRef: + previous_block_ref -> block 95 + previous_records_count = 3 + [8123,417] + [9001,351] + [14020,692] + + | + v + +BIG BLOCK 95 + +Alice old record #1 +Alice old record #2 +Alice old record #3 + PreviousUserRecordsRef: + previous_block_ref -> block 81 + previous_records_count = 1 + [4412,380] + + | + v + +BIG BLOCK 81 +... +``` + +Так клиент получает всю историю пользователя назад блок за блоком, но скачивает только конкретные нужные byte ranges. + +--- + +# 20. Hash большого блока + +В footer записывается: + +```text +bytes[32] block_hash +``` + +`block_hash` вычисляется как: + +```text +SHA256( + все байты большого блока + от magic + через header + через creator_login + через PreviousBlockReference table + через все user records и server navigation metadata + через footer closer_login_length + closer_login +) +``` + +Само поле `block_hash` и `closer_signature` в hash НЕ входят. + +--- + +# 21. Footer: кто закрыл блок, hash и подпись + +В конце каждого большого блока MUST быть: + +```text +u8 closer_login_length +bytes[N] closer_login UTF-8 +bytes[32] block_hash +bytes[64] closer_signature +``` + +Логические значения: + +```text +1. кто сформировал/закрыл блок; +2. hash точных bytes блока; +3. подпись закрывающего аккаунта. +``` + +`closer_login` MUST точно совпадать с `creator_login` из header. + +Если значения отличаются — блок невалиден. + +--- + +# 22. Подпись большого блока + +Алгоритм v1: + +```text +Ed25519 +``` + +Подписываемый payload: + +```text +ASCII("SHINE-ARCHIVE-V1") || block_hash +``` + +То есть: + +```text +closer_signature = +Ed25519Sign( + closer_private_key, + "SHINE-ARCHIVE-V1" || block_hash +) +``` + +Для первой реализации `closer_private_key` может быть root private key SHiNE-аккаунта `creator_login` / `closer_login`. + +Public key для проверки берётся из соответствующего User PDA в Solana. + +--- + +# 23. Archive head в Solana User PDA + +Отдельный PDA создавать не требуется. + +Используется тот же существующий User PDA. + +Новый block type: + +```text +100 +``` + +Формат: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Размер: + +```text +66 байт +``` + +Solana тем самым хранит: + +```text +где находится текущая голова -> Arweave TX ID +какие exact bytes являются правильными -> SHA-256 +``` + +--- + +# 24. Обновление Solana — обычный User PDA update + +**Отдельная Solana instruction для archive head не требуется.** + +Архивный сервер использует существующий обычный механизм обновления собственного User PDA: + +1. читает текущий PDA; +2. сохраняет все существующие поля; +3. меняет/добавляет только block type `100`; +4. пересобирает новую PDA-запись; +5. выполняет существующую root-signature проверку; +6. `client key` подписывает/оплачивает Solana transaction; +7. ждёт `finalized`. + +Важно: все новые Java/JS/Rust сериализаторы обязаны понимать block type `100` и сохранять его при обычных обновлениях PDA. + +--- + +# 25. `TestFreeAvatarArweaveService` + +Старый сервис больше не нужен как avatar test service. + +Его следует переименовать, например, в: + +```text +ArweaveArchiveService +``` + +и переделать только под серверную фиксацию. + +Нужно оставить/переиспользовать: + +- JWK parsing; +- wallet address; +- reward calculation; +- balance check; +- RSA-PSS signing; +- Arweave HTTP. + +Удалить avatar-specific части: + +- PNG/JPEG/WebP проверки; +- avatar quota; +- лимит 128 KiB; +- avatar DAO; +- avatar-specific API. + +Новый сервис должен уметь: + +```text +publishArchive(...) +waitForConfirmation(...) +``` + +и корректно работать с большими/chunked uploads. + +--- + +# 26. Ожидание закрепления Arweave + +После получения TX ID сервер НЕ обновляет Solana сразу. + +Он ждёт заданное число подтверждений. + +Рекомендуемые настройки: + +```properties +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 +``` + +Только после `ARWEAVE_CONFIRMED` разрешён update User PDA. + +--- + +# 27. Порядок публикации + +Строго: + +```text +1. Синхронизировать/иметь актуальные локальные блоки. + +2. Прочитать archive_chain_cursor. + +3. Зафиксировать snapshot новых диапазонов. + +4. Создать archive_publish_job. + +5. Собрать большой SHINE-ARCHIVE v1.0. + +6. В header записать creator_login. + +7. В начало блока записать FULL_HISTORY список всех предыдущих больших блоков: + block_number + hash + Arweave TX ID. + +8. Для каждого пользователя сформировать server navigation metadata: + previous big block + список всех offset/size его записей там. + +9. Посчитать block_hash. + +10. В footer записать closer_login, block_hash, signature. + +11. Загрузить файл в Arweave. + +12. Сохранить TX ID в publish job. + +13. Дождаться закрепления Arweave. + +14. Обычным User PDA update записать block type 100: + archive_tx_id + archive_hash. + +15. Дождаться Solana finalized. + +16. В одной локальной DB transaction сдвинуть archive_chain_cursor. + +17. Отметить job CURSORS_COMMITTED. +``` + +Если новых данных нет — пустой большой блок создавать не нужно. + +--- + +# 28. Crash recovery + +## Crash после snapshot + +Продолжить с теми же frozen ranges. + +## Crash после построения файла + +Использовать тот же файл и тот же hash. + +## Crash после Arweave upload + +Не загружать второй раз. Использовать сохранённый TX ID. + +## Crash после Arweave confirmation + +Продолжить с User PDA update. + +## Crash после Solana submit + +Сначала проверить текущий PDA/transaction. + +## Crash после Solana finalized, но до cursor commit + +Если PDA уже содержит ожидаемые: + +```text +archive_tx_id +archive_hash +``` + +то безопасно выполнить локальный cursor commit. + +--- + +# 29. Настройки scheduler + +```properties +archive.publish.enabled=false +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/opt/shine/secrets/root.key +archive.solana.clientKeyPath=/opt/shine/secrets/client.key +archive.solana.commitment=finalized +``` + +Для первой версии разумно начать с: + +```text +720 минут = раз в 12 часов +``` + +Частота обычной межсерверной синхронизации меняется независимо. + +--- + +# 30. Проверка большого блока клиентом + +Клиент SHOULD выполнять: + +```text +1. Скачать начало файла. +2. Проверить magic. +3. Прочитать major/minor. +4. Прочитать header_size. +5. Прочитать creator_login. +6. Докачать весь header + PreviousBlockReference table. +7. При необходимости получить нужные previous block TXID/hash. +8. Скачать нужные user record ranges. +9. При полной проверке файла пересчитать block_hash. +10. Проверить closer_login == creator_login. +11. Получить public key автора из User PDA. +12. Проверить Ed25519 signature. +``` + +--- + +# 31. Пример навигации Alice + +Допустим клиент уже имеет актуальную запись Alice из block 100. + +Server navigation metadata говорит: + +```text +previous_block_ref = 94 +previous_records_count = 3 + +[8123, 417] +[9001, 351] +[14020, 692] +``` + +Из начала block 100: + +```text +PreviousBlockReference[94]: + block_number = 95 + block_hash = H95 + arweave_tx_id = TX95 +``` + +Клиент: + +```text +1. идёт в TX95; +2. проверяет H95; +3. делает Range на 8123/417; +4. Range на 9001/351; +5. Range на 14020/692; +6. получает все три Alice records из block 95; +7. из server navigation metadata последней Alice record получает следующий переход, например на block 81; +8. повторяет. +``` + +То есть история читается: + +```text +100 -> 95 -> 81 -> 63 -> ... +``` + +но на каждом шаге скачиваются только конкретные записи данного пользователя. + +--- + +# 32. Почему hash и TX ID нужны одновременно + +Нельзя оставить только TX ID. + +Правильная модель: + +```text +block_number +-> логический номер + +block_hash +-> криптографическая идентичность + +arweave_tx_id +-> текущее физическое расположение +``` + +Если в будущем большой блок будет перенесён из Arweave в другое хранилище, его hash останется тем же. + +--- + +# 33. Будущее `PARTIAL_HISTORY` + +В первой реализации используется только: + +```text +references_mode = FULL_HISTORY +``` + +На будущее уже зарезервирован: + +```text +PARTIAL_HISTORY +``` + +Будущая версия сможет, например, хранить: + +- последние N больших блоков; +- checkpoint-блоки; +- только реально используемые ссылки; +- многоуровневый индекс. + +Но это НЕ требуется для v1. + +--- + +# 34. Итоговые бинарные структуры v1.0 + +## Header + +```text +bytes[13] magic = "SHINE-ARCHIVE" +u8 version_major +u8 version_minor +u32 header_size +u32 block_number +u64 created_at_ms +u8 creator_login_length +bytes[N] creator_login +u8 references_mode +u32 references_count +u16 reference_entry_size +u32 records_count +``` + +## PreviousBlockReference + +```text +u32 block_number +bytes[32] block_hash +bytes[32] arweave_tx_id +``` + +Размер v1: + +```text +68 bytes +``` + +## PreviousUserRecordsRef + +```text +u32 previous_block_ref +u32 previous_records_count + +repeat previous_records_count: + u32 previous_record_offset + u32 previous_record_size +``` + +## User archived envelope — рекомендуемая форма + +```text +u32 archived_record_size +u32 raw_user_record_size +bytes[N] raw_user_record +u8 navigation_flags +[optional PreviousUserRecordsRef] +``` + +## Footer + +```text +u8 closer_login_length +bytes[N] closer_login +bytes[32] block_hash +bytes[64] closer_signature +``` + +## Solana User PDA block 100 + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +--- + +# 35. Обязательные проверки v1 + +Publisher MUST: + +- не изменять frozen job после snapshot; +- проверять cursor hash; +- не двигать cursor до Solana finalized; +- не публиковать пустую дельту; +- записывать полный список предыдущих больших блоков; +- хранить hash и TX ID каждого previous block; +- записывать creator login в header; +- повторять тот же login в footer; +- подписывать hash большого блока; +- добавлять для пользователя ссылку на предыдущий большой блок и offsets/sizes всех его записей там. + +Reader MUST: + +- проверять magic/version; +- проверять границы offsets; +- проверять hash скачанного большого блока; +- проверять `closer_login == creator_login`; +- проверять подпись; +- проверять, что скачанные user records действительно принадлежат ожидаемому пользователю; +- не считать Arweave TX ID самостоятельным доказательством корректности содержимого. + +--- + +# 36. Реализационный checklist + +## Solana + +- [ ] Добавить User PDA block type `100`. +- [ ] Добавить `archive_tx_id [32]`. +- [ ] Добавить `archive_hash [32]`. +- [ ] Научить обычный User PDA update читать и сохранять block 100. +- [ ] Серверу дать возможность обычным update менять block 100. +- [ ] Ждать `finalized`. + +## Database + +- [ ] `archive_chain_cursor`. +- [ ] `archive_publish_job`. +- [ ] `archive_publish_job_chain`. + +## Arweave + +- [ ] Переименовать `TestFreeAvatarArweaveService` в `ArweaveArchiveService`. +- [ ] Удалить avatar-specific код. +- [ ] Реализовать большие/chunked uploads. +- [ ] Реализовать ожидание confirmations. +- [ ] Сохранять TX ID до ожидания подтверждений. + +## Большой блок + +- [ ] Magic `SHINE-ARCHIVE`. +- [ ] `version_major u8`. +- [ ] `version_minor u8`. +- [ ] `header_size`. +- [ ] `block_number u32`. +- [ ] `created_at_ms u64`. +- [ ] `creator_login` в header. +- [ ] `FULL_HISTORY` previous block table. +- [ ] `block_number + block_hash + txid` для каждого previous block. +- [ ] Server-side `PreviousUserRecordsRef`. +- [ ] Список всех `offset + size` пользовательских записей из предыдущего big block. +- [ ] `closer_login + block_hash + signature` в footer. + +## Scheduler / recovery + +- [ ] Настраиваемый interval. +- [ ] Initial delay. +- [ ] Один active job. +- [ ] Resume после restart. +- [ ] Cursor commit только после Solana finalized. + +--- + +# 37. Краткая итоговая модель + +```text +SOLANA USER PDA + | + +--> block 100 + archive TX ID + archive SHA-256 + + | + v + +SHINE-ARCHIVE block N + | + | HEADER: + | version + | block number + | time + | CREATOR LOGIN + | + | PREVIOUS BLOCK TABLE: + | #0 number/hash/TXID + | #1 number/hash/TXID + | ... все предыдущие в v1 + | + | USER DATA: + | original signed user records + | server navigation metadata + | previous block ref + | ALL previous user record offsets/sizes + | + | FOOTER: + | same creator/closer login + | hash + | Ed25519 signature + v + +SHINE-ARCHIVE block N-1 + | + v +... +``` + +Это завершает согласованную спецификацию `SHiNE Archive Protocol v1.0`. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 45c479a2..75f87a51 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -234,13 +234,6 @@ function buildThreadRoute(messageRef, selector) { }); } -function buildChannelSettingsKey(ownerBlockchainName, channelName) { - const ownerBch = String(ownerBlockchainName || '').trim(); - const name = String(channelName || '').trim(); - if (!ownerBch || !name) return ''; - return `${ownerBch}/${name}`; -} - function getChannelScrollRoot() { return document.getElementById('app-screen'); } @@ -286,7 +279,9 @@ function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) { function createChannelReadTracker({ screen, routeKey, - settingKey, + ownerBlockchainName, + channelName, + initializeIfMissing = false, unreadCount, messagesCount, initialSeenCount, @@ -295,7 +290,9 @@ function createChannelReadTracker({ }) { const login = String(state.session.login || '').trim(); const storagePwd = state.session.storagePwdInMemory; - const canWrite = !!(settingKey && login && storagePwd); + const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim(); + const cleanChannelName = String(channelName || '').trim(); + const canWrite = !!(cleanOwnerBlockchainName && cleanChannelName && login && storagePwd); const safeMessagesCount = Math.max(0, Number(messagesCount || 0)); const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount)); const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null; @@ -303,6 +300,7 @@ function createChannelReadTracker({ let desiredSeenCount = safeInitialSeenCount; let persistedSeenCount = safeInitialSeenCount; + let initialPersistPending = !!initializeIfMissing; let inFlight = false; let disposed = false; let rafId = 0; @@ -327,7 +325,7 @@ function createChannelReadTracker({ const flush = async () => { if (disposed || !canWrite) return; const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount)); - if (next <= persistedSeenCount) return; + if (next <= persistedSeenCount && !initialPersistPending) return; if (inFlight) { queueFlush(120); return; @@ -335,16 +333,16 @@ function createChannelReadTracker({ inFlight = true; try { - await authService.upsertUserSetting({ + await authService.setChannelReadState({ login, - settingType: 1, - settingKey, + ownerBlockchainName: cleanOwnerBlockchainName, + channelName: cleanChannelName, + readCount: next, timeMs: Date.now(), - valueText: '', - valueNum: next, storagePwd, }); persistedSeenCount = next; + initialPersistPending = false; if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount); } catch (error) { if (typeof onPersistError === 'function') onPersistError(error); @@ -399,6 +397,8 @@ function createChannelReadTracker({ // Opening a channel must NOT mark the whole channel as read. // Only cards actually crossed by the viewport tracker advance desiredSeenCount. + // Exception: if the server has no row yet, persist the current client baseline once. + if (initialPersistPending) queueFlush(80); window.setTimeout(() => measure(), 120); const cleanup = () => { @@ -1457,6 +1457,7 @@ async function loadFromApi(route, channelId) { const isAuthorized = !!currentSessionLogin; let unreadCount = 0; let messagesCount = 0; + let readStateInitialized = false; let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; @@ -1571,6 +1572,7 @@ async function loadFromApi(route, channelId) { } unreadCount = Number(channel?.unreadCount || 0); messagesCount = Number(channel?.messagesCount || 0); + readStateInitialized = !!channel?.readStateInitialized; selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), @@ -1664,6 +1666,7 @@ async function loadFromApi(route, channelId) { messagesCount, isOwnChannel, isSubscribed, + readStateInitialized, selector, }; } @@ -1793,12 +1796,12 @@ function renderChannelMetaEventCard(event) { function likeCategoryCounts(post) { const total = Math.max(0, Number(post?.likesCount || 0)); - const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); - const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0))); + const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); + const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0))); return { shining, - official: Math.max(0, primary - shining), - others: Math.max(0, total - primary), + official, + all: total, total, }; } @@ -1815,7 +1818,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining @@ -1831,7 +1834,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); modal?.addEventListener('click', (event) => event.stopPropagation()); - let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining'; + let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining'; let payload = null; const renderTab = () => { @@ -1841,7 +1844,14 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining button.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); if (!payload) return; - const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : []; + const shiningRows = Array.isArray(payload?.shining) ? payload.shining : []; + const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : []; + const otherRows = Array.isArray(payload?.others) ? payload.others : []; + const rows = activeTab === 'shining' + ? shiningRows + : activeTab === 'official' + ? [...shiningRows, ...officialOnlyRows] + : [...shiningRows, ...officialOnlyRows, ...otherRows]; list.innerHTML = ''; rows.forEach((row) => { const userButton = document.createElement('button'); @@ -1869,7 +1879,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining }); list.append(userButton); }); - status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.'; + status.textContent = rows.length ? '' : 'В этом списке пока никого нет.'; }; overlay.querySelectorAll('[data-like-tab]').forEach((button) => { @@ -1882,11 +1892,19 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining (async () => { try { - payload = await authService.getMessageLikes(messageRef, 1000); + payload = await authService.getMessageLikes(messageRef); if (!overlay.isConnected) return; - ['shining', 'official', 'others'].forEach((key) => { + const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0; + const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0; + const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0; + const tabCounts = { + shining: shiningCount, + official: shiningCount + officialOnlyCount, + all: shiningCount + officialOnlyCount + otherCount, + }; + Object.entries(tabCounts).forEach(([key, value]) => { const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`); - if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0); + if (countEl) countEl.textContent = String(value); }); renderTab(); } catch (error) { @@ -1909,25 +1927,16 @@ function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) { - `; document.body.append(layer); const popup = layer.querySelector('.channel-like-popup'); - const rect = anchor.getBoundingClientRect(); const width = Math.min(330, Math.max(270, window.innerWidth - 24)); - const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); - const estimatedHeight = 255; - const top = rect.bottom + estimatedHeight < window.innerHeight - 8 - ? rect.bottom + 8 - : Math.max(8, rect.top - estimatedHeight - 8); popup.style.width = `${width}px`; - popup.style.left = `${left}px`; - popup.style.top = `${top}px`; const close = () => layer.remove(); layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); @@ -2378,10 +2387,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { const tracker = createChannelReadTracker({ screen, routeKey, - settingKey: buildChannelSettingsKey( - channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, - channelData.channel?.name || channelData.channel?.channelName, - ), + ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, + channelName: channelData.channel?.name || channelData.channel?.channelName, + initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized), unreadCount, messagesCount, initialSeenCount: readCount, @@ -2532,18 +2540,13 @@ export function render({ navigate, route, chrome }) { }); if (disposed) return; - const readSettingKey = buildChannelSettingsKey( - apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, - apiData.channel?.name || apiData.channel?.channelName, - ); try { - await authService.upsertUserSetting({ + await authService.setChannelReadState({ login, - settingType: 1, - settingKey: readSettingKey, + ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, + channelName: apiData.channel?.name || apiData.channel?.channelName, + readCount: Math.max(0, Number(apiData.messagesCount || 0)), timeMs: Date.now(), - valueText: '', - valueNum: Math.max(0, Number(apiData.messagesCount || 0)), storagePwd, }); } catch (readStateError) { diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 031cfa8f..c6f71c00 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -30,6 +30,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) { `; } +function friendsMetricHtml(stats = {}) { + const friends = Number(stats.friendsCount || 0); + const closeFriends = Number(stats.closeFriendsCount || 0); + return ` + `; +} + +function hasContacts(card) { + return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim()); +} + function spiritualPathDetailHtml(card) { const value = String(card?.spiritualPath || '').trim(); return ``; @@ -102,52 +121,51 @@ export function render({ navigate, chrome }) { .map((value) => String(value || '').trim()) .filter(Boolean) .join(' '); + const displayName = fullName || card.login || login || 'Профиль'; const about = String(card.about || '').trim(); + const spiritualPath = String(card.spiritualPath || '').trim(); + const contactsVisible = hasContacts(card); const title = topbar.querySelector('.topbar__title'); if (title) title.textContent = card.login || login || 'Профиль'; body.innerHTML = ` - ${fullName ? `` : ''} - - ${about ? `` : ''} - - `; const avatarSlot = body.querySelector('.user-profile-avatar-slot'); avatarSlot?.append(renderUserAvatar({ diff --git a/shine-UI/js/pages/user-profile-list-view.js b/shine-UI/js/pages/user-profile-list-view.js index ef70a336..80098b33 100644 --- a/shine-UI/js/pages/user-profile-list-view.js +++ b/shine-UI/js/pages/user-profile-list-view.js @@ -13,32 +13,93 @@ function parseAvatar(raw) { } const TITLES = { - friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт', + friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт', primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие', channels_owned: 'Каналы', channels_following: 'Подписки на каналы', }; -export function render({navigate, route, chrome}) { - const login = String(route?.params?.login || '').trim(); - const kind = String(route?.params?.kind || '').trim(); - const screen = document.createElement('section'); screen.className = 'stack'; - const body = document.createElement('div'); body.className = 'stack'; - const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...'; - chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } })); - screen.append( - status, - body, - ); +function friendTabsHtml(activeKind) { + return ` +
+ + +
`; +} - (async () => { +export function render({ navigate, route, chrome }) { + const login = String(route?.params?.login || '').trim(); + const initialKind = String(route?.params?.kind || '').trim(); + let activeKind = initialKind; + const isFriendsScreen = initialKind === 'friends' || initialKind === 'close_friends'; + + const screen = document.createElement('section'); + screen.className = 'stack'; + const body = document.createElement('div'); + body.className = 'stack'; + const status = document.createElement('div'); + status.className = 'status-line'; + status.textContent = 'Загрузка...'; + + chrome?.setTopbar(createTopBar({ + title: TITLES[initialKind] || 'Список', + back: { label: '←', onClick: () => navigateBack() }, + })); + + if (isFriendsScreen) { + const tabs = document.createElement('div'); + tabs.innerHTML = friendTabsHtml(activeKind); + screen.append(tabs.firstElementChild); + } + screen.append(status, body); + + let loadGeneration = 0; + + function renderRelationRows(rows) { + rows.forEach((row) => { + const el = document.createElement('button'); + el.type = 'button'; + el.className = 'ui-button card row profile-list-row'; + el.append(renderUserAvatar({ + login: row.login, + firstName: row.firstName, + lastName: row.lastName, + avatar: parseAvatar(row.avatarAr), + size: 'md', + })); + const fullName = userDisplayName(row); + const t = document.createElement('div'); + t.className = 'profile-list-row-text'; + const marks = [ + row.relationType && row.relationType !== 'none' ? ({ contact: 'контакт', friend: 'друг', close_friend: 'близкий друг' }[row.relationType] || row.relationType) : '', + row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '', + row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '', + row.primaryConfirmed ? 'основной подтверждён ✓' : '', + row.shineConfirmed ? 'сияющий подтверждён ✓' : '', + ].filter(Boolean).join(' · '); + t.innerHTML = `${fullName}@${String(row.login || '')}${marks ? ` · ${marks}` : ''}`; + el.append(t); + el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); + body.append(el); + }); + } + + async function load(kind) { + const generation = ++loadGeneration; + status.className = 'status-line'; + status.textContent = 'Загрузка...'; + body.replaceChildren(); try { if (kind === 'channels_owned' || kind === 'channels_following') { const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0); + if (generation !== loadGeneration) return; const rows = Array.isArray(payload?.channels) ? payload.channels : []; rows.forEach((row) => { - const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row'; + const el = document.createElement('button'); + el.type = 'button'; + el.className = 'ui-button card row profile-list-row'; el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' })); - const t = document.createElement('div'); t.className = 'profile-list-row-text'; + const t = document.createElement('div'); + t.className = 'profile-list-row-text'; t.innerHTML = `${String(row.displayName || row.slug || '')}${String(row.ownerLogin || '')} / ${String(row.slug || '')}`; el.append(t); el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`)); @@ -47,25 +108,35 @@ export function render({navigate, route, chrome}) { status.textContent = rows.length ? '' : 'Список пуст.'; return; } + const payload = await authService.listUserProfileRelations(login, kind, 200, 0); + if (generation !== loadGeneration) return; const rows = Array.isArray(payload?.users) ? payload.users : []; - rows.forEach((row) => { - const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row'; - el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' })); - const fullName = userDisplayName(row); - const t = document.createElement('div'); t.className = 'profile-list-row-text'; - const marks = [ - row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '', - row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '', - row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '', - row.primaryConfirmed ? 'основной подтверждён ✓' : '', - row.shineConfirmed ? 'сияющий подтверждён ✓' : '', - ].filter(Boolean).join(' · '); - t.innerHTML = `${fullName}${String(row.login || '')}${marks ? ` · ${marks}` : ''}`; - el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el); - }); + renderRelationRows(rows); status.textContent = rows.length ? '' : 'Список пуст.'; - } catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; } - })(); + } catch (e) { + if (generation !== loadGeneration) return; + status.className = 'status-line is-unavailable'; + status.textContent = `Ошибка: ${e.message || 'unknown'}`; + } + } + + if (isFriendsScreen) { + screen.addEventListener('click', (event) => { + const tab = event.target.closest('[data-friend-kind]'); + if (!tab) return; + const nextKind = String(tab.dataset.friendKind || ''); + if (!nextKind || nextKind === activeKind) return; + activeKind = nextKind; + screen.querySelectorAll('[data-friend-kind]').forEach((button) => { + const selected = button.dataset.friendKind === activeKind; + button.classList.toggle('is-active', selected); + button.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + void load(activeKind); + }); + } + + void load(activeKind); return screen; } diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index 3c9b3700..b2f0f48b 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -38,6 +38,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) { `; } +function friendsMetricHtml(stats = {}) { + const friends = Number(stats.friendsCount || 0); + const closeFriends = Number(stats.closeFriendsCount || 0); + return ` + `; +} + +function hasContacts(card) { + return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim()); +} + function spiritualPathDetailHtml(card) { const value = String(card?.spiritualPath || '').trim(); return ``; @@ -174,54 +193,53 @@ export function render({ navigate, route, chrome }) { .map((value) => String(value || '').trim()) .filter(Boolean) .join(' '); + const displayName = fullName || card.login || 'Профиль'; const about = String(card.about || '').trim(); + const spiritualPath = String(card.spiritualPath || '').trim(); + const contactsVisible = hasContacts(card); const title = header.querySelector('.topbar__title'); if (title) title.textContent = card.login; body.innerHTML = ` - ${fullName ? `` : ''} - - ${about ? `` : ''} - - ` : ''}`; const avatarSlot = body.querySelector('.user-profile-avatar-slot'); avatarSlot?.append(renderUserAvatar({ diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index b8e4879b..5348ea28 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1595,13 +1595,13 @@ export class AuthService { return response.payload || {}; } - async getMessageLikes(message, limit = 1000) { + async getMessageLikes(message) { const normalizedMessage = { blockchainName: String(message?.blockchainName || '').trim(), blockNumber: Number(message?.blockNumber), blockHash: String(message?.blockHash || '').trim(), }; - const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit }); + const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage }); if (response.status !== 200) throw opError('GetMessageLikes', response); return response.payload || {}; } @@ -3056,6 +3056,56 @@ export class AuthService { return response.payload || {}; } + async setChannelReadState({ + login, + ownerBlockchainName, + channelName, + readCount, + timeMs, + storagePwd, + }) { + const cleanLogin = String(login || '').trim(); + const cleanOwnerBch = String(ownerBlockchainName || '').trim(); + const cleanChannelName = String(channelName || '').trim(); + const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0))); + const cleanTimeMs = Math.trunc(Number(timeMs)); + if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) { + throw new Error('Не переданы login/ownerBlockchainName/channelName'); + } + if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) { + throw new Error('Не передан корректный timeMs'); + } + if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.'); + + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey'); + const privateKey = await importPkcs8Ed25519(clientPrivPkcs8); + const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); + + const preimage = [ + 'SHiNe/ChannelReadState:', + escapeUserSettingPart(cleanLogin), + escapeUserSettingPart(cleanOwnerBch), + escapeUserSettingPart(cleanChannelName), + String(cleanTimeMs), + String(cleanReadCount), + ].join('|'); + const signature = await signBase64(privateKey, preimage); + + const response = await this.ws.request('SetChannelReadState', { + login: cleanLogin, + owner_bch_name: cleanOwnerBch, + channel_name: cleanChannelName, + read_count: cleanReadCount, + time_ms: cleanTimeMs, + client_key: clientKey, + signature, + }); + if (response.status !== 200) throw opError('SetChannelReadState', response); + return response.payload || {}; + } + async upsertUserSetting({ login, settingType, diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index bb9a72cc..8ddf33a2 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -696,6 +696,9 @@ .channel-like-popup { position: fixed; z-index: 1601; + left: 50%; + top: clamp(72px, 18vh, 180px); + transform: translateX(-50%); padding: 14px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 18px; diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index 8a470721..cb09eda5 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -1805,3 +1805,165 @@ .profiles-actions { margin-top: 4px; } .profiles-close-all { margin-top: 4px; } + +/* ===== 2026-09-09: profile layout — identity, social row, about card ===== */ +.user-profile-identity { + width: min(88%, 340px); + margin: 12px auto 0; + text-align: center; +} + +.user-profile-identity .user-profile-full-name { + width: 100%; + margin: 0; + color: rgba(244, 248, 255, 0.96); + font-size: clamp(17px, 4.8vw, 20px); + font-weight: 700; + line-height: 1.2; +} + +.user-profile-login { + margin-top: 4px; + color: rgba(199, 211, 229, 0.62); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.user-profile-social-metrics { + width: min(92%, 360px); + margin: 22px auto 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + justify-items: center; + gap: 4px; +} + +.user-profile-social-metrics .user-profile-metric { + width: 100%; + min-height: 58px; + gap: 5px; + justify-content: flex-start; +} + +.user-profile-social-metrics .user-profile-metric-circle, +.user-profile-metric-value-combined { + width: auto; + min-width: 0; + height: auto; + min-height: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: rgba(240, 246, 255, 0.95); + font-size: clamp(17px, 4.7vw, 20px); + font-weight: 800; + line-height: 1.1; + white-space: nowrap; +} + +.user-profile-social-metrics .user-profile-metric-label { + position: static; + width: 100%; + min-height: 0; + transform: none; + color: rgba(203, 216, 236, 0.72); + font-size: 11px; + line-height: 1.15; + text-align: center; +} + +.user-profile-about-card { + width: min(88%, 344px); + margin: 20px auto 0; +} + +.user-profile-about-title { + margin: 0 0 7px 3px; + color: rgba(224, 233, 247, 0.82); + font-size: 12px; + font-weight: 650; + line-height: 1.2; +} + +.user-profile-about-field { + min-height: 68px; + padding: 13px 14px; + border: 1px solid rgba(210, 220, 234, 0.14); + border-radius: 14px; + background: rgba(162, 172, 186, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: rgba(236, 242, 251, 0.9); + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.user-profile-about-field.is-empty { + color: rgba(199, 210, 226, 0.46); +} + +.user-profile-detail-links.user-profile-detail-links--below-about { + width: min(88%, 344px); + margin: 10px auto 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + gap: 10px; +} + +.user-profile-detail-links--below-about > :first-child { + justify-self: start; +} + +.user-profile-detail-links--below-about > :last-child { + justify-self: end; +} + +.user-profile-detail-links--below-about button { + width: auto; + min-width: 0; + padding-left: 6px; + padding-right: 6px; +} + +.profile-list-tabs { + width: min(92%, 360px); + margin: 4px auto 10px; + padding: 3px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + border: 1px solid rgba(205, 221, 244, 0.12); + border-radius: 14px; + background: rgba(14, 25, 43, 0.24); +} + +.profile-list-tab { + min-height: 36px; + padding: 7px 10px; + border: 0; + border-radius: 11px; + background: transparent; + color: rgba(207, 219, 237, 0.72); + font: inherit; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.profile-list-tab.is-active { + background: rgba(156, 177, 204, 0.18); + color: rgba(246, 249, 255, 0.97); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +@media (max-height: 700px) { + .user-profile-identity { margin-top: 8px; } + .user-profile-social-metrics { margin-top: 16px; } + .user-profile-about-card { margin-top: 15px; } +}