Добавить состояние чтения каналов

This commit is contained in:
AidarKC
2026-09-09 18:59:54 +03:00
parent 2d059e9ff5
commit 8e86872aa7
29 changed files with 2538 additions and 231 deletions
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */ /** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:"; 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". */ /** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe"; public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -37,6 +37,7 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_18 = 18; public static final int SCHEMA_VERSION_18 = 18;
public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_19 = 19;
public static final int SCHEMA_VERSION_20 = 20; 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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -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_V18_RESOURCE = "postgres/migration_v18.sql";
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.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_V20_RESOURCE = "postgres/migration_v20.sql";
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -206,6 +208,10 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE); runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
currentVersion = SCHEMA_VERSION_20; currentVersion = SCHEMA_VERSION_20;
} }
if (currentVersion < SCHEMA_VERSION_21) {
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
currentVersion = SCHEMA_VERSION_21;
}
} }
} }
@@ -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<String> 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<String> 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;
}
}
}
@@ -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;
@@ -425,6 +425,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_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 ( CREATE TABLE IF NOT EXISTS ip_geo_cache (
ip TEXT PRIMARY KEY, ip TEXT PRIMARY KEY,
geo TEXT, 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) 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; ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT; COMMIT;
@@ -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_ListGroupChats200_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_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_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_GetChannelsCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_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; 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_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_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_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.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_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("ListGroupChats200", new Net_ListGroupChats200_Handler()),
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()), Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_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("ListGroupChats200", Net_ListGroupChats200_Request.class),
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
Map.entry("GetUserCounters", Net_GetUserCounters_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("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
@@ -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.blockchain.entyties.Net_AddBlock_Response;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser; import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.sync.AddBlockSyncService; import server.sync.AddBlockSyncService;
import shine.db.channels.ChannelNameRules; import shine.db.channels.ChannelNameRules;
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -586,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
} }
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
dbWriter.appendBlockAndState( List<String> counterChangedLogins = dbWriter.appendBlockAndState(
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
for (String changedLogin : counterChangedLogins) {
UserCountersSupport.pushChanged(changedLogin);
}
if (chat200CreateSeed != null) { if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed); upsertChat200StateFromCreate(chat200CreateSeed);
} }
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO; import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.ChannelReadStateDAO;
import shine.db.MsgSubType;
import shine.db.dao.UserParamsDAO; import shine.db.dao.UserParamsDAO;
import shine.db.dao.UserNotificationsStateDAO; import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
@@ -19,6 +21,8 @@ import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.ArrayList;
import java.util.List;
/** /**
* BlockchainWriter запись блока в БД и формирование файловой версии цепочки. * BlockchainWriter запись блока в БД и формирование файловой версии цепочки.
@@ -44,6 +48,7 @@ public final class BlockchainWriter {
private final ChannelNameStateDAO channelNameStateDAO; private final ChannelNameStateDAO channelNameStateDAO;
private final UserParamsDAO userParamsDAO; private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO; private final UserNotificationsStateDAO userNotificationsStateDAO;
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
private final FileStoreUtil fs = FileStoreUtil.getInstance(); private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO, public BlockchainWriter(BlocksDAO blocksDAO,
@@ -58,7 +63,7 @@ public final class BlockchainWriter {
this.userNotificationsStateDAO = userNotificationsStateDAO; this.userNotificationsStateDAO = userNotificationsStateDAO;
} }
public void appendBlockAndState(String blockchainName, public List<String> appendBlockAndState(String blockchainName,
BchBlockEntry block, BchBlockEntry block,
BlockchainStateEntry st, BlockchainStateEntry st,
BlockEntry be, BlockEntry be,
@@ -75,6 +80,7 @@ public final class BlockchainWriter {
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
boolean committed = false; boolean committed = false;
List<String> counterChangedLogins = new ArrayList<>();
try (Connection c = shine.db.DbController.getInstance().getConnection()) { try (Connection c = shine.db.DbController.getInstance().getConnection()) {
c.setAutoCommit(false); c.setAutoCommit(false);
try { try {
@@ -108,6 +114,29 @@ public final class BlockchainWriter {
userNotificationsStateDAO.upsert(c, notificationEntry); 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(); c.commit();
committed = true; committed = true;
} catch (Exception e) { } catch (Exception e) {
@@ -142,6 +171,17 @@ public final class BlockchainWriter {
// 4) После успешной подмены чистим временные артефакты. // 4) После успешной подмены чистим временные артефакты.
cleanupWriteArtifactsBestEffort(blockchainName); 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) { private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
@@ -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 { static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank()) return 0; if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
String key = userSettingsChannelKey(ownerBch, channelName); || channelName == null || channelName.isBlank()) return 0;
String sql = """ long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
SELECT value_num .getUnreadCount(c, viewerLogin, ownerBch, channelName);
FROM user_settings return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
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);
} }
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
@@ -20,7 +20,6 @@ import java.util.List;
public class Net_GetMessageLikes_Handler implements JsonMessageHandler { public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
private static final int HARD_LIMIT = 1000;
@Override @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { 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"); 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()) { try (Connection c = DbController.getInstance().getConnection()) {
String sql = """ String sql = """
SELECT rs.from_login, SELECT rs.from_login,
@@ -59,7 +57,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
AND rs.to_block_number = ? AND rs.to_block_number = ?
AND rs.to_block_hash = ? AND rs.to_block_hash = ?
ORDER BY LOWER(rs.from_login) ORDER BY LOWER(rs.from_login)
LIMIT ?
"""; """;
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>(); List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
@@ -69,16 +66,8 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
ps.setString(1, message.getBlockchainName().trim()); ps.setString(1, message.getBlockchainName().trim());
ps.setInt(2, message.getBlockNumber()); ps.setInt(2, message.getBlockNumber());
ps.setBytes(3, blockHash); ps.setBytes(3, blockHash);
ps.setInt(4, requested + 1);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
int accepted = 0;
boolean truncated = false;
while (rs.next()) { while (rs.next()) {
if (accepted >= requested) {
truncated = true;
break;
}
accepted++;
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
user.setLogin(rs.getString("from_login")); user.setLogin(rs.getString("from_login"));
user.setFirstName(rs.getString("first_name")); user.setFirstName(rs.getString("first_name"));
@@ -100,7 +89,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
resp.setOfficial(official); resp.setOfficial(official);
resp.setOthers(others); resp.setOthers(others);
resp.setTotal(shining.size() + official.size() + others.size()); resp.setTotal(shining.size() + official.size() + others.size());
resp.setTruncated(truncated); resp.setTruncated(false);
return resp; return resp;
} }
} }
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType; import shine.db.MsgSubType;
import shine.db.DbController; import shine.db.DbController;
import shine.db.dao.ChannelReadStateDAO;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@@ -83,9 +84,11 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef); row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin); boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
row.setUnreadCount(ownChannel ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
? 0 ? null
: ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount())); : 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); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) { if (lastPost != null) {
@@ -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("|", "\\|");
}
}
@@ -6,8 +6,8 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.push.WsEventSender;
import shine.db.DbController; import shine.db.DbController;
import shine.db.MsgSubType;
import shine.db.dao.UserNotificationSeenStateDAO; import shine.db.dao.UserNotificationSeenStateDAO;
import shine.db.dao.ChannelReadStateDAO;
import shine.db.dao.UserNotificationsStateDAO; import shine.db.dao.UserNotificationsStateDAO;
import java.sql.Connection; import java.sql.Connection;
@@ -89,28 +89,6 @@ public final class UserCountersSupport {
} }
private static long countChannelsUnread(Connection c, String login) throws Exception { private static long countChannelsUnread(Connection c, String login) throws Exception {
String sql = """ return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
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;
} }
} }
@@ -27,6 +27,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private ChannelRef channel; private ChannelRef channel;
private int messagesCount; private int messagesCount;
private int unreadCount; private int unreadCount;
private boolean readStateInitialized;
private LastMessage lastMessage; private LastMessage lastMessage;
public ChannelRef getChannel() { return channel; } public ChannelRef getChannel() { return channel; }
@@ -38,6 +39,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public int getUnreadCount() { return unreadCount; } public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = 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 LastMessage getLastMessage() { return lastMessage; }
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; } public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
} }
@@ -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; }
}
@@ -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; }
}
@@ -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.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; 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.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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.DbController; import shine.db.DbController;
@@ -101,9 +100,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
resp.setSetting_type(settingType); resp.setSetting_type(settingType);
resp.setSetting_key(settingKey); resp.setSetting_key(settingKey);
resp.setTime_ms(timeMs); resp.setTime_ms(timeMs);
if (settingType == 1) {
UserCountersSupport.pushChanged(login);
}
return resp; return resp;
} }
} catch (SQLException e) { } catch (SQLException e) {
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.5 client.version=1.12.6
server.version=1.10.2 server.version=1.10.3
+76 -7
View File
@@ -21,9 +21,11 @@
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. 6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
7. `ListGroupChats200` — отдает список групповых чатов типа `200`. 7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. 8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
> >
@@ -110,6 +112,8 @@
"channelRoot": { "blockNumber": 456, "blockHash": "..." } "channelRoot": { "blockNumber": 456, "blockHash": "..." }
}, },
"messagesCount": 90, "messagesCount": 90,
"unreadCount": 0,
"readStateInitialized": false,
"lastMessage": { "lastMessage": {
"messageRef": { "blockNumber": 1002, "blockHash": "..." }, "messageRef": { "blockNumber": 1002, "blockHash": "..." },
"text": "актуальный текст", "text": "актуальный текст",
@@ -274,7 +278,7 @@
Возвращает пользователей, которые поставили лайк конкретному сообщению канала. Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. - `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
- `limit` ограничивается сервером сверху значением `1000`. - `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
- Пользователи группируются по состоянию профиля: - Пользователи группируются по состоянию профиля:
- `shining``account_role=primary` и `shine_status=shining`; - `shining``account_role=primary` и `shine_status=shining`;
- `official``account_role=primary`, но без `shine_status=shining`; - `official``account_role=primary`, но без `shine_status=shining`;
@@ -290,8 +294,7 @@
"blockchainName": "bob-001", "blockchainName": "bob-001",
"blockNumber": 140, "blockNumber": 140,
"blockHash": "..." "blockHash": "..."
}, }
"limit": 1000
} }
} }
``` ```
@@ -322,6 +325,8 @@
- `bad_hash``message.blockHash` не является корректным hex-хэшем блока. - `bad_hash``message.blockHash` не является корректным hex-хэшем блока.
- `internal_error` — внутренняя ошибка чтения. - `internal_error` — внутренняя ошибка чтения.
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
--- ---
## 5) GetPersonalDiary ## 5) GetPersonalDiary
@@ -379,7 +384,69 @@
--- ---
## 7) ListGroupChats200 ## 7) SetChannelReadState
Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан.
- Требует авторизованное WebSocket-соединение.
- `login` должен совпадать с текущим авторизованным пользователем.
- Подпись строится client key пользователя по строке:
```text
SHiNe/ChannelReadState:<login>|<owner_bch_name>|<channel_name>|<time_ms>|<read_count>
```
### 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": "<base64-client-public-key>",
"signature": "<base64-ed25519-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 ### Request
```json ```json
@@ -419,7 +486,7 @@
--- ---
## 8) GetGroupDialog ## 9) GetGroupDialog
### Request ### Request
```json ```json
@@ -474,6 +541,8 @@
- `message_not_found` - `message_not_found`
- `limit_too_large` - `limit_too_large`
- `channel_name_already_exists` - `channel_name_already_exists`
- `CHANNEL_NOT_FOLLOWED`
- `CHANNEL_NOT_FOUND`
- `internal_error` - `internal_error`
--- ---
+30 -4
View File
@@ -9,6 +9,7 @@
- `GetChannelMessages` — сообщения конкретного канала. - `GetChannelMessages` — сообщения конкретного канала.
- `GetMessageThread` — дерево обсуждения для сообщения. - `GetMessageThread` — дерево обсуждения для сообщения.
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
- `SetChannelReadState` — подписанный watermark чтения канала.
2. **UI вкладки Каналы**: 2. **UI вкладки Каналы**:
- при открытии пытается загрузить реальный feed с сервера; - при открытии пытается загрузить реальный feed с сервера;
@@ -35,13 +36,15 @@
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
3. Для первого `messages[0]` вызвать `GetMessageThread`. 3. Для первого `messages[0]` вызвать `GetMessageThread`.
4. Для первого `messages[0]` вызвать `GetMessageLikes`. 4. Для первого `messages[0]` вызвать `GetMessageLikes`.
5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`.
### Ошибки ### Ошибки
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
3. `GetMessageThread` с несуществующим block -> `message_not_found`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`.
4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. 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", "blockchainName": "TestUser1-001",
"blockNumber": 123, "blockNumber": 123,
"blockHash": "<hash-from-GetChannelMessages>" "blockHash": "<hash-from-GetChannelMessages>"
}, }
"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": "<base64-client-public-key>",
"signature": "<base64-ed25519-signature>"
} }
} }
``` ```
@@ -120,6 +139,8 @@
- у каждой записи есть: - у каждой записи есть:
- `channel.channelRoot.blockNumber`, - `channel.channelRoot.blockNumber`,
- `messagesCount`, - `messagesCount`,
- `unreadCount`,
- `readStateInitialized`,
- `lastMessage` (может быть null, если сообщений нет). - `lastMessage` (может быть null, если сообщений нет).
### GetChannelMessages ### GetChannelMessages
@@ -137,7 +158,12 @@
### GetMessageLikes ### GetMessageLikes
- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. - `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей.
- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. - у каждого пользователя есть `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`. - `rawBlockB64` добавлен только в `GetMessageThread`.
+1
View File
@@ -49,6 +49,7 @@
| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению |
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя | | `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
File diff suppressed because it is too large Load Diff
+52 -49
View File
@@ -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() { function getChannelScrollRoot() {
return document.getElementById('app-screen'); return document.getElementById('app-screen');
} }
@@ -286,7 +279,9 @@ function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
function createChannelReadTracker({ function createChannelReadTracker({
screen, screen,
routeKey, routeKey,
settingKey, ownerBlockchainName,
channelName,
initializeIfMissing = false,
unreadCount, unreadCount,
messagesCount, messagesCount,
initialSeenCount, initialSeenCount,
@@ -295,7 +290,9 @@ function createChannelReadTracker({
}) { }) {
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory; 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 safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount)); const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null; const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
@@ -303,6 +300,7 @@ function createChannelReadTracker({
let desiredSeenCount = safeInitialSeenCount; let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount; let persistedSeenCount = safeInitialSeenCount;
let initialPersistPending = !!initializeIfMissing;
let inFlight = false; let inFlight = false;
let disposed = false; let disposed = false;
let rafId = 0; let rafId = 0;
@@ -327,7 +325,7 @@ function createChannelReadTracker({
const flush = async () => { const flush = async () => {
if (disposed || !canWrite) return; if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount)); const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return; if (next <= persistedSeenCount && !initialPersistPending) return;
if (inFlight) { if (inFlight) {
queueFlush(120); queueFlush(120);
return; return;
@@ -335,16 +333,16 @@ function createChannelReadTracker({
inFlight = true; inFlight = true;
try { try {
await authService.upsertUserSetting({ await authService.setChannelReadState({
login, login,
settingType: 1, ownerBlockchainName: cleanOwnerBlockchainName,
settingKey, channelName: cleanChannelName,
readCount: next,
timeMs: Date.now(), timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd, storagePwd,
}); });
persistedSeenCount = next; persistedSeenCount = next;
initialPersistPending = false;
if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount); if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount);
} catch (error) { } catch (error) {
if (typeof onPersistError === 'function') onPersistError(error); if (typeof onPersistError === 'function') onPersistError(error);
@@ -399,6 +397,8 @@ function createChannelReadTracker({
// Opening a channel must NOT mark the whole channel as read. // Opening a channel must NOT mark the whole channel as read.
// Only cards actually crossed by the viewport tracker advance desiredSeenCount. // 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); window.setTimeout(() => measure(), 120);
const cleanup = () => { const cleanup = () => {
@@ -1457,6 +1457,7 @@ async function loadFromApi(route, channelId) {
const isAuthorized = !!currentSessionLogin; const isAuthorized = !!currentSessionLogin;
let unreadCount = 0; let unreadCount = 0;
let messagesCount = 0; let messagesCount = 0;
let readStateInitialized = false;
let cachedFeed = null; let cachedFeed = null;
const ensureFeed = async () => { const ensureFeed = async () => {
if (cachedFeed) return cachedFeed; if (cachedFeed) return cachedFeed;
@@ -1571,6 +1572,7 @@ async function loadFromApi(route, channelId) {
} }
unreadCount = Number(channel?.unreadCount || 0); unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || 0); messagesCount = Number(channel?.messagesCount || 0);
readStateInitialized = !!channel?.readStateInitialized;
selector = { selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName), ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
@@ -1664,6 +1666,7 @@ async function loadFromApi(route, channelId) {
messagesCount, messagesCount,
isOwnChannel, isOwnChannel,
isSubscribed, isSubscribed,
readStateInitialized,
selector, selector,
}; };
} }
@@ -1793,12 +1796,12 @@ function renderChannelMetaEventCard(event) {
function likeCategoryCounts(post) { function likeCategoryCounts(post) {
const total = Math.max(0, Number(post?.likesCount || 0)); const total = Math.max(0, Number(post?.likesCount || 0));
const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0))); const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0)));
return { return {
shining, shining,
official: Math.max(0, primary - shining), official,
others: Math.max(0, total - primary), all: total,
total, total,
}; };
} }
@@ -1815,7 +1818,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
<div class="channel-likes-tabs" role="tablist"> <div class="channel-likes-tabs" role="tablist">
<button type="button" class="ui-button" data-like-tab="shining">Сияющие <span data-like-tab-count="shining"></span></button> <button type="button" class="ui-button" data-like-tab="shining">Сияющие <span data-like-tab-count="shining"></span></button>
<button type="button" class="ui-button" data-like-tab="official">Официальные <span data-like-tab-count="official"></span></button> <button type="button" class="ui-button" data-like-tab="official">Официальные <span data-like-tab-count="official"></span></button>
<button type="button" class="ui-button" data-like-tab="others">Остальные <span data-like-tab-count="others"></span></button> <button type="button" class="ui-button" data-like-tab="all">Все <span data-like-tab-count="all"></span></button>
</div> </div>
<div class="channel-likes-modal__status">Загрузка...</div> <div class="channel-likes-modal__status">Загрузка...</div>
<div class="channel-likes-user-list"></div> <div class="channel-likes-user-list"></div>
@@ -1831,7 +1834,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
modal?.addEventListener('click', (event) => event.stopPropagation()); 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; let payload = null;
const renderTab = () => { const renderTab = () => {
@@ -1841,7 +1844,14 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
button.setAttribute('aria-selected', isActive ? 'true' : 'false'); button.setAttribute('aria-selected', isActive ? 'true' : 'false');
}); });
if (!payload) return; 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 = ''; list.innerHTML = '';
rows.forEach((row) => { rows.forEach((row) => {
const userButton = document.createElement('button'); const userButton = document.createElement('button');
@@ -1869,7 +1879,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
}); });
list.append(userButton); list.append(userButton);
}); });
status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.'; status.textContent = rows.length ? '' : 'В этом списке пока никого нет.';
}; };
overlay.querySelectorAll('[data-like-tab]').forEach((button) => { overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
@@ -1882,11 +1892,19 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
(async () => { (async () => {
try { try {
payload = await authService.getMessageLikes(messageRef, 1000); payload = await authService.getMessageLikes(messageRef);
if (!overlay.isConnected) return; 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}"]`); 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(); renderTab();
} catch (error) { } catch (error) {
@@ -1909,25 +1927,16 @@ function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) {
<div class="channel-like-popup__counts"> <div class="channel-like-popup__counts">
<button type="button" class="ui-button channel-like-count" data-like-list="shining"><b>${counts.shining}</b><span>Сияющие</span></button> <button type="button" class="ui-button channel-like-count" data-like-list="shining"><b>${counts.shining}</b><span>Сияющие</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="official"><b>${counts.official}</b><span>Официальные</span></button> <button type="button" class="ui-button channel-like-count" data-like-list="official"><b>${counts.official}</b><span>Официальные</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="others"><b>${counts.others}</b><span>Остальные</span></button> <button type="button" class="ui-button channel-like-count" data-like-list="all"><b>${counts.all}</b><span>Все</span></button>
</div> </div>
<div class="channel-like-popup__total">Всего лайков: <b>${counts.total}</b></div>
<button type="button" class="ui-button channel-like-popup__action">${post.reactionState === 'liked' ? 'Убрать свой лайк' : 'Добавить свой лайк'}</button> <button type="button" class="ui-button channel-like-popup__action">${post.reactionState === 'liked' ? 'Убрать свой лайк' : 'Добавить свой лайк'}</button>
<button type="button" class="ui-button channel-like-popup__close">Закрыть</button> <button type="button" class="ui-button channel-like-popup__close">Закрыть</button>
</section> </section>
`; `;
document.body.append(layer); document.body.append(layer);
const popup = layer.querySelector('.channel-like-popup'); const popup = layer.querySelector('.channel-like-popup');
const rect = anchor.getBoundingClientRect();
const width = Math.min(330, Math.max(270, window.innerWidth - 24)); 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.width = `${width}px`;
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
const close = () => layer.remove(); const close = () => layer.remove();
layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); layer.addEventListener('click', (event) => { if (event.target === layer) close(); });
@@ -2378,10 +2387,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const tracker = createChannelReadTracker({ const tracker = createChannelReadTracker({
screen, screen,
routeKey, routeKey,
settingKey: buildChannelSettingsKey( ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, channelName: channelData.channel?.name || channelData.channel?.channelName,
channelData.channel?.name || channelData.channel?.channelName, initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
),
unreadCount, unreadCount,
messagesCount, messagesCount,
initialSeenCount: readCount, initialSeenCount: readCount,
@@ -2532,18 +2540,13 @@ export function render({ navigate, route, chrome }) {
}); });
if (disposed) return; if (disposed) return;
const readSettingKey = buildChannelSettingsKey(
apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
apiData.channel?.name || apiData.channel?.channelName,
);
try { try {
await authService.upsertUserSetting({ await authService.setChannelReadState({
login, login,
settingType: 1, ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
settingKey: readSettingKey, channelName: apiData.channel?.name || apiData.channel?.channelName,
readCount: Math.max(0, Number(apiData.messagesCount || 0)),
timeMs: Date.now(), timeMs: Date.now(),
valueText: '',
valueNum: Math.max(0, Number(apiData.messagesCount || 0)),
storagePwd, storagePwd,
}); });
} catch (readStateError) { } catch (readStateError) {
+47 -29
View File
@@ -30,6 +30,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
</button>`; </button>`;
} }
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) { function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim(); const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`; return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
@@ -102,52 +121,51 @@ export function render({ navigate, chrome }) {
.map((value) => String(value || '').trim()) .map((value) => String(value || '').trim())
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const displayName = fullName || card.login || login || 'Профиль';
const about = String(card.about || '').trim(); const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = topbar.querySelector('.topbar__title'); const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль'; if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = ` body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}"> <div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })} ${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })} ${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div> <div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div> </div>
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''} <div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-channel-metrics"> <div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
</div> </div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
<div class="user-profile-actions-wrap"> <div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем"> <div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"> <button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"> <button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
</button> <button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true">
</button>
</div> </div>
</div> </div>`;
<div class="user-profile-detail-links" aria-label="Дополнительная информация о профиле">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot'); const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({ avatarSlot?.append(renderUserAvatar({
+103 -32
View File
@@ -13,32 +13,93 @@ function parseAvatar(raw) {
} }
const TITLES = { const TITLES = {
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт', friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт',
primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие', primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
channels_owned: 'Каналы', channels_following: 'Подписки на каналы', channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
}; };
export function render({navigate, route, chrome}) { function friendTabsHtml(activeKind) {
const login = String(route?.params?.login || '').trim(); return `
const kind = String(route?.params?.kind || '').trim(); <div class="profile-list-tabs" role="tablist" aria-label="Тип друзей">
const screen = document.createElement('section'); screen.className = 'stack'; <button type="button" class="profile-list-tab${activeKind === 'friends' ? ' is-active' : ''}" data-friend-kind="friends" role="tab" aria-selected="${activeKind === 'friends'}">Друзья</button>
const body = document.createElement('div'); body.className = 'stack'; <button type="button" class="profile-list-tab${activeKind === 'close_friends' ? ' is-active' : ''}" data-friend-kind="close_friends" role="tab" aria-selected="${activeKind === 'close_friends'}">Близкие друзья</button>
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...'; </div>`;
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } })); }
screen.append(
status,
body,
);
(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 = `<b>${fullName}</b><small>@${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
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 { try {
if (kind === 'channels_owned' || kind === 'channels_following') { if (kind === 'channels_owned' || kind === 'channels_following') {
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0); 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 : []; const rows = Array.isArray(payload?.channels) ? payload.channels : [];
rows.forEach((row) => { 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' })); 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 = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`; t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
el.append(t); el.append(t);
el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`)); 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 ? '' : 'Список пуст.'; status.textContent = rows.length ? '' : 'Список пуст.';
return; return;
} }
const payload = await authService.listUserProfileRelations(login, kind, 200, 0); const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
if (generation !== loadGeneration) return;
const rows = Array.isArray(payload?.users) ? payload.users : []; const rows = Array.isArray(payload?.users) ? payload.users : [];
rows.forEach((row) => { renderRelationRows(rows);
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 = `<b>${fullName}</b><small>${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
});
status.textContent = rows.length ? '' : 'Список пуст.'; 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; return screen;
} }
+47 -29
View File
@@ -38,6 +38,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
</button>`; </button>`;
} }
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) { function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim(); const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`; return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
@@ -174,54 +193,53 @@ export function render({ navigate, route, chrome }) {
.map((value) => String(value || '').trim()) .map((value) => String(value || '').trim())
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const displayName = fullName || card.login || 'Профиль';
const about = String(card.about || '').trim(); const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = header.querySelector('.topbar__title'); const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login; if (title) title.textContent = card.login;
body.innerHTML = ` body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}"> <div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })} ${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })} ${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div> <div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div> </div>
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''} <div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-channel-metrics"> <div class="user-profile-login">@${escapeHtml(card.login)}</div>
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
</div> </div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
${!isSelf ? ` ${!isSelf ? `
<div class="user-profile-actions-wrap"> <div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div> <div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем"> <div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false"> <button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
${addIconHtml()} <button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
</button> <button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
</button>
</div> </div>
</div>` : ''} </div>` : ''}`;
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot'); const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({ avatarSlot?.append(renderUserAvatar({
+52 -2
View File
@@ -1595,13 +1595,13 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async getMessageLikes(message, limit = 1000) { async getMessageLikes(message) {
const normalizedMessage = { const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(), blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber), blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(), 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); if (response.status !== 200) throw opError('GetMessageLikes', response);
return response.payload || {}; return response.payload || {};
} }
@@ -3056,6 +3056,56 @@ export class AuthService {
return response.payload || {}; 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({ async upsertUserSetting({
login, login,
settingType, settingType,
+3
View File
@@ -696,6 +696,9 @@
.channel-like-popup { .channel-like-popup {
position: fixed; position: fixed;
z-index: 1601; z-index: 1601;
left: 50%;
top: clamp(72px, 18vh, 180px);
transform: translateX(-50%);
padding: 14px; padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px; border-radius: 18px;
+162
View File
@@ -1805,3 +1805,165 @@
.profiles-actions { margin-top: 4px; } .profiles-actions { margin-top: 4px; }
.profiles-close-all { 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; }
}