SHA256
Добавить состояние чтения каналов
This commit is contained in:
+3
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
public static final int SCHEMA_VERSION_19 = 19;
|
||||
public static final int SCHEMA_VERSION_20 = 20;
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -57,6 +58,7 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -206,6 +208,10 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_21;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -2009,7 +2029,7 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,21,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
+4
@@ -82,6 +82,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_H
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
@@ -91,6 +92,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalD
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
@@ -217,6 +219,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
@@ -310,6 +313,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
|
||||
+7
-1
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -586,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
}
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
@@ -19,6 +21,8 @@ import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
@@ -44,6 +48,7 @@ public final class BlockchainWriter {
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
@@ -58,7 +63,7 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -75,6 +80,7 @@ public final class BlockchainWriter {
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
@@ -108,6 +114,29 @@ public final class BlockchainWriter {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
@@ -142,6 +171,17 @@ public final class BlockchainWriter {
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
|
||||
+5
-31
@@ -144,38 +144,12 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
||||
String name = channelName == null ? "" : channelName.trim();
|
||||
return bch + "/" + name;
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
|
||||
+1
-12
@@ -20,7 +20,6 @@ import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||
private static final int HARD_LIMIT = 1000;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
@@ -42,7 +41,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
|
||||
int requested = req.getLimit() == null ? HARD_LIMIT : Math.max(1, Math.min(HARD_LIMIT, req.getLimit()));
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String sql = """
|
||||
SELECT rs.from_login,
|
||||
@@ -59,7 +57,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
AND rs.to_block_number = ?
|
||||
AND rs.to_block_hash = ?
|
||||
ORDER BY LOWER(rs.from_login)
|
||||
LIMIT ?
|
||||
""";
|
||||
|
||||
List<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.setInt(2, message.getBlockNumber());
|
||||
ps.setBytes(3, blockHash);
|
||||
ps.setInt(4, requested + 1);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
int accepted = 0;
|
||||
boolean truncated = false;
|
||||
while (rs.next()) {
|
||||
if (accepted >= requested) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
accepted++;
|
||||
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||
user.setLogin(rs.getString("from_login"));
|
||||
user.setFirstName(rs.getString("first_name"));
|
||||
@@ -100,7 +89,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
resp.setOfficial(official);
|
||||
resp.setOthers(others);
|
||||
resp.setTotal(shining.size() + official.size() + others.size());
|
||||
resp.setTruncated(truncated);
|
||||
resp.setTruncated(false);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -83,9 +84,11 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
row.setUnreadCount(ownChannel
|
||||
? 0
|
||||
: ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||
row.setReadStateInitialized(readState != null);
|
||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+137
@@ -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("|", "\\|");
|
||||
}
|
||||
}
|
||||
+2
-24
@@ -6,8 +6,8 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import shine.db.DbController;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -89,28 +89,6 @@ public final class UserCountersSupport {
|
||||
}
|
||||
|
||||
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||
String sql = """
|
||||
SELECT cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number
|
||||
FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=?
|
||||
""";
|
||||
long total = 0L;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, MsgSubType.CONNECTION_FOLLOW);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String ownerBch = rs.getString("to_bch_name");
|
||||
int rootNumber = rs.getInt("root_number");
|
||||
if (ownerBch == null || ownerBch.isBlank()) continue;
|
||||
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, rootNumber);
|
||||
if (meta == null || meta.channelName == null || meta.channelName.isBlank()) continue;
|
||||
if (meta.channelTypeCode == 0 || "stories".equalsIgnoreCase(meta.channelName)) continue;
|
||||
int messages = ChannelsReadSupport.countPosts(c, ownerBch, rootNumber);
|
||||
total += Math.max(0, ChannelsReadSupport.countUnreadMessages(c, login, ownerBch, meta.channelName, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -27,6 +27,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private int unreadCount;
|
||||
private boolean readStateInitialized;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
@@ -38,6 +39,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
+34
@@ -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; }
|
||||
}
|
||||
+34
@@ -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; }
|
||||
}
|
||||
-4
@@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
@@ -101,9 +100,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
||||
resp.setSetting_type(settingType);
|
||||
resp.setSetting_key(settingKey);
|
||||
resp.setTime_ms(timeMs);
|
||||
if (settingType == 1) {
|
||||
UserCountersSupport.pushChanged(login);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.5
|
||||
server.version=1.10.2
|
||||
client.version=1.12.6
|
||||
server.version=1.10.3
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
|
||||
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
|
||||
7. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
|
||||
|
||||
8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
|
||||
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
@@ -110,6 +112,8 @@
|
||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 90,
|
||||
"unreadCount": 0,
|
||||
"readStateInitialized": false,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||
"text": "актуальный текст",
|
||||
@@ -274,7 +278,7 @@
|
||||
Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
|
||||
|
||||
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
|
||||
- `limit` ограничивается сервером сверху значением `1000`.
|
||||
- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
|
||||
- Пользователи группируются по состоянию профиля:
|
||||
- `shining` — `account_role=primary` и `shine_status=shining`;
|
||||
- `official` — `account_role=primary`, но без `shine_status=shining`;
|
||||
@@ -290,8 +294,7 @@
|
||||
"blockchainName": "bob-001",
|
||||
"blockNumber": 140,
|
||||
"blockHash": "..."
|
||||
},
|
||||
"limit": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -322,6 +325,8 @@
|
||||
- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока.
|
||||
- `internal_error` — внутренняя ошибка чтения.
|
||||
|
||||
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
|
||||
|
||||
---
|
||||
|
||||
## 5) GetPersonalDiary
|
||||
@@ -379,7 +384,69 @@
|
||||
|
||||
---
|
||||
|
||||
## 7) ListGroupChats200
|
||||
## 7) SetChannelReadState
|
||||
|
||||
Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан.
|
||||
|
||||
- Требует авторизованное WebSocket-соединение.
|
||||
- `login` должен совпадать с текущим авторизованным пользователем.
|
||||
- Подпись строится client key пользователя по строке:
|
||||
|
||||
```text
|
||||
SHiNe/ChannelReadState:<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
|
||||
```json
|
||||
@@ -419,7 +486,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 8) GetGroupDialog
|
||||
## 9) GetGroupDialog
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -474,6 +541,8 @@
|
||||
- `message_not_found`
|
||||
- `limit_too_large`
|
||||
- `channel_name_already_exists`
|
||||
- `CHANNEL_NOT_FOLLOWED`
|
||||
- `CHANNEL_NOT_FOUND`
|
||||
- `internal_error`
|
||||
|
||||
---
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- `GetChannelMessages` — сообщения конкретного канала.
|
||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
|
||||
- `SetChannelReadState` — подписанный watermark чтения канала.
|
||||
|
||||
2. **UI вкладки Каналы**:
|
||||
- при открытии пытается загрузить реальный feed с сервера;
|
||||
@@ -35,13 +36,15 @@
|
||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||
4. Для первого `messages[0]` вызвать `GetMessageLikes`.
|
||||
5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`.
|
||||
|
||||
### Ошибки
|
||||
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||
4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`.
|
||||
5. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`.
|
||||
6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,8 +107,24 @@
|
||||
"blockchainName": "TestUser1-001",
|
||||
"blockNumber": 123,
|
||||
"blockHash": "<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`,
|
||||
- `messagesCount`,
|
||||
- `unreadCount`,
|
||||
- `readStateInitialized`,
|
||||
- `lastMessage` (может быть null, если сообщений нет).
|
||||
|
||||
### GetChannelMessages
|
||||
@@ -137,7 +158,12 @@
|
||||
### GetMessageLikes
|
||||
- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей.
|
||||
- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`.
|
||||
- `payload.truncated=true` означает, что сервер обрезал список по `limit`.
|
||||
- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации.
|
||||
|
||||
### SetChannelReadState
|
||||
- `payload.read_count` — сохраненная позиция чтения.
|
||||
- `payload.unread_count` — пересчитанный unread для канала.
|
||||
- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция.
|
||||
|
||||
### Важно по совместимости
|
||||
- `rawBlockB64` добавлен только в `GetMessageThread`.
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению |
|
||||
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,13 +234,6 @@ function buildThreadRoute(messageRef, selector) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const name = String(channelName || '').trim();
|
||||
if (!ownerBch || !name) return '';
|
||||
return `${ownerBch}/${name}`;
|
||||
}
|
||||
|
||||
function getChannelScrollRoot() {
|
||||
return document.getElementById('app-screen');
|
||||
}
|
||||
@@ -286,7 +279,9 @@ function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
|
||||
function createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
settingKey,
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
initializeIfMissing = false,
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount,
|
||||
@@ -295,7 +290,9 @@ function createChannelReadTracker({
|
||||
}) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
const canWrite = !!(settingKey && login && storagePwd);
|
||||
const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim();
|
||||
const cleanChannelName = String(channelName || '').trim();
|
||||
const canWrite = !!(cleanOwnerBlockchainName && cleanChannelName && login && storagePwd);
|
||||
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
|
||||
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
|
||||
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
|
||||
@@ -303,6 +300,7 @@ function createChannelReadTracker({
|
||||
|
||||
let desiredSeenCount = safeInitialSeenCount;
|
||||
let persistedSeenCount = safeInitialSeenCount;
|
||||
let initialPersistPending = !!initializeIfMissing;
|
||||
let inFlight = false;
|
||||
let disposed = false;
|
||||
let rafId = 0;
|
||||
@@ -327,7 +325,7 @@ function createChannelReadTracker({
|
||||
const flush = async () => {
|
||||
if (disposed || !canWrite) return;
|
||||
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
||||
if (next <= persistedSeenCount) return;
|
||||
if (next <= persistedSeenCount && !initialPersistPending) return;
|
||||
if (inFlight) {
|
||||
queueFlush(120);
|
||||
return;
|
||||
@@ -335,16 +333,16 @@ function createChannelReadTracker({
|
||||
|
||||
inFlight = true;
|
||||
try {
|
||||
await authService.upsertUserSetting({
|
||||
await authService.setChannelReadState({
|
||||
login,
|
||||
settingType: 1,
|
||||
settingKey,
|
||||
ownerBlockchainName: cleanOwnerBlockchainName,
|
||||
channelName: cleanChannelName,
|
||||
readCount: next,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: next,
|
||||
storagePwd,
|
||||
});
|
||||
persistedSeenCount = next;
|
||||
initialPersistPending = false;
|
||||
if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount);
|
||||
} catch (error) {
|
||||
if (typeof onPersistError === 'function') onPersistError(error);
|
||||
@@ -399,6 +397,8 @@ function createChannelReadTracker({
|
||||
|
||||
// Opening a channel must NOT mark the whole channel as read.
|
||||
// Only cards actually crossed by the viewport tracker advance desiredSeenCount.
|
||||
// Exception: if the server has no row yet, persist the current client baseline once.
|
||||
if (initialPersistPending) queueFlush(80);
|
||||
window.setTimeout(() => measure(), 120);
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -1457,6 +1457,7 @@ async function loadFromApi(route, channelId) {
|
||||
const isAuthorized = !!currentSessionLogin;
|
||||
let unreadCount = 0;
|
||||
let messagesCount = 0;
|
||||
let readStateInitialized = false;
|
||||
let cachedFeed = null;
|
||||
const ensureFeed = async () => {
|
||||
if (cachedFeed) return cachedFeed;
|
||||
@@ -1571,6 +1572,7 @@ async function loadFromApi(route, channelId) {
|
||||
}
|
||||
unreadCount = Number(channel?.unreadCount || 0);
|
||||
messagesCount = Number(channel?.messagesCount || 0);
|
||||
readStateInitialized = !!channel?.readStateInitialized;
|
||||
selector = {
|
||||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||||
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
||||
@@ -1664,6 +1666,7 @@ async function loadFromApi(route, channelId) {
|
||||
messagesCount,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
readStateInitialized,
|
||||
selector,
|
||||
};
|
||||
}
|
||||
@@ -1793,12 +1796,12 @@ function renderChannelMetaEventCard(event) {
|
||||
|
||||
function likeCategoryCounts(post) {
|
||||
const total = Math.max(0, Number(post?.likesCount || 0));
|
||||
const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
|
||||
const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0)));
|
||||
const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
|
||||
const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0)));
|
||||
return {
|
||||
shining,
|
||||
official: Math.max(0, primary - shining),
|
||||
others: Math.max(0, total - primary),
|
||||
official,
|
||||
all: total,
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -1815,7 +1818,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
|
||||
<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="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 class="channel-likes-modal__status">Загрузка...</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(); });
|
||||
modal?.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining';
|
||||
let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining';
|
||||
let payload = null;
|
||||
|
||||
const renderTab = () => {
|
||||
@@ -1841,7 +1844,14 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
|
||||
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
});
|
||||
if (!payload) return;
|
||||
const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : [];
|
||||
const shiningRows = Array.isArray(payload?.shining) ? payload.shining : [];
|
||||
const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : [];
|
||||
const otherRows = Array.isArray(payload?.others) ? payload.others : [];
|
||||
const rows = activeTab === 'shining'
|
||||
? shiningRows
|
||||
: activeTab === 'official'
|
||||
? [...shiningRows, ...officialOnlyRows]
|
||||
: [...shiningRows, ...officialOnlyRows, ...otherRows];
|
||||
list.innerHTML = '';
|
||||
rows.forEach((row) => {
|
||||
const userButton = document.createElement('button');
|
||||
@@ -1869,7 +1879,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
|
||||
});
|
||||
list.append(userButton);
|
||||
});
|
||||
status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.';
|
||||
status.textContent = rows.length ? '' : 'В этом списке пока никого нет.';
|
||||
};
|
||||
|
||||
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
|
||||
@@ -1882,11 +1892,19 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
payload = await authService.getMessageLikes(messageRef, 1000);
|
||||
payload = await authService.getMessageLikes(messageRef);
|
||||
if (!overlay.isConnected) return;
|
||||
['shining', 'official', 'others'].forEach((key) => {
|
||||
const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0;
|
||||
const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0;
|
||||
const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0;
|
||||
const tabCounts = {
|
||||
shining: shiningCount,
|
||||
official: shiningCount + officialOnlyCount,
|
||||
all: shiningCount + officialOnlyCount + otherCount,
|
||||
};
|
||||
Object.entries(tabCounts).forEach(([key, value]) => {
|
||||
const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`);
|
||||
if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0);
|
||||
if (countEl) countEl.textContent = String(value);
|
||||
});
|
||||
renderTab();
|
||||
} catch (error) {
|
||||
@@ -1909,25 +1927,16 @@ function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) {
|
||||
<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="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 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__close">Закрыть</button>
|
||||
</section>
|
||||
`;
|
||||
document.body.append(layer);
|
||||
const popup = layer.querySelector('.channel-like-popup');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(330, Math.max(270, window.innerWidth - 24));
|
||||
const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
|
||||
const estimatedHeight = 255;
|
||||
const top = rect.bottom + estimatedHeight < window.innerHeight - 8
|
||||
? rect.bottom + 8
|
||||
: Math.max(8, rect.top - estimatedHeight - 8);
|
||||
popup.style.width = `${width}px`;
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
|
||||
const close = () => layer.remove();
|
||||
layer.addEventListener('click', (event) => { if (event.target === layer) close(); });
|
||||
@@ -2378,10 +2387,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const tracker = createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
settingKey: buildChannelSettingsKey(
|
||||
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||
channelData.channel?.name || channelData.channel?.channelName,
|
||||
),
|
||||
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||
channelName: channelData.channel?.name || channelData.channel?.channelName,
|
||||
initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount: readCount,
|
||||
@@ -2532,18 +2540,13 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
if (disposed) return;
|
||||
|
||||
const readSettingKey = buildChannelSettingsKey(
|
||||
apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
|
||||
apiData.channel?.name || apiData.channel?.channelName,
|
||||
);
|
||||
try {
|
||||
await authService.upsertUserSetting({
|
||||
await authService.setChannelReadState({
|
||||
login,
|
||||
settingType: 1,
|
||||
settingKey: readSettingKey,
|
||||
ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
|
||||
channelName: apiData.channel?.name || apiData.channel?.channelName,
|
||||
readCount: Math.max(0, Number(apiData.messagesCount || 0)),
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: Math.max(0, Number(apiData.messagesCount || 0)),
|
||||
storagePwd,
|
||||
});
|
||||
} catch (readStateError) {
|
||||
|
||||
@@ -30,6 +30,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
</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) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
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())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const displayName = fullName || card.login || login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = topbar.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login || login || 'Профиль';
|
||||
|
||||
body.innerHTML = `
|
||||
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
|
||||
|
||||
<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: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||
<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>
|
||||
|
||||
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
|
||||
|
||||
<div class="user-profile-channel-metrics">
|
||||
${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 class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login || login)}</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" aria-label="Действия со своим профилем">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>`;
|
||||
</div>`;
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
avatarSlot?.append(renderUserAvatar({
|
||||
|
||||
@@ -13,32 +13,93 @@ function parseAvatar(raw) {
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт',
|
||||
friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт',
|
||||
primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
|
||||
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
|
||||
};
|
||||
|
||||
export function render({navigate, route, chrome}) {
|
||||
const login = String(route?.params?.login || '').trim();
|
||||
const kind = String(route?.params?.kind || '').trim();
|
||||
const screen = document.createElement('section'); screen.className = 'stack';
|
||||
const body = document.createElement('div'); body.className = 'stack';
|
||||
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
|
||||
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } }));
|
||||
screen.append(
|
||||
status,
|
||||
body,
|
||||
);
|
||||
function friendTabsHtml(activeKind) {
|
||||
return `
|
||||
<div class="profile-list-tabs" role="tablist" aria-label="Тип друзей">
|
||||
<button type="button" class="profile-list-tab${activeKind === 'friends' ? ' is-active' : ''}" data-friend-kind="friends" role="tab" aria-selected="${activeKind === 'friends'}">Друзья</button>
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
(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 {
|
||||
if (kind === 'channels_owned' || kind === 'channels_following') {
|
||||
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
|
||||
if (generation !== loadGeneration) return;
|
||||
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
const t = document.createElement('div');
|
||||
t.className = 'profile-list-row-text';
|
||||
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
|
||||
el.append(t);
|
||||
el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`));
|
||||
@@ -47,25 +108,35 @@ export function render({navigate, route, chrome}) {
|
||||
status.textContent = rows.length ? '' : 'Список пуст.';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
|
||||
if (generation !== loadGeneration) return;
|
||||
const rows = Array.isArray(payload?.users) ? payload.users : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const fullName = userDisplayName(row);
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
const marks = [
|
||||
row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '',
|
||||
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
|
||||
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
|
||||
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
|
||||
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
t.innerHTML = `<b>${fullName}</b><small>${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
|
||||
el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
|
||||
});
|
||||
renderRelationRows(rows);
|
||||
status.textContent = rows.length ? '' : 'Список пуст.';
|
||||
} catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; }
|
||||
})();
|
||||
} catch (e) {
|
||||
if (generation !== loadGeneration) return;
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Ошибка: ${e.message || 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFriendsScreen) {
|
||||
screen.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest('[data-friend-kind]');
|
||||
if (!tab) return;
|
||||
const nextKind = String(tab.dataset.friendKind || '');
|
||||
if (!nextKind || nextKind === activeKind) return;
|
||||
activeKind = nextKind;
|
||||
screen.querySelectorAll('[data-friend-kind]').forEach((button) => {
|
||||
const selected = button.dataset.friendKind === activeKind;
|
||||
button.classList.toggle('is-active', selected);
|
||||
button.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
void load(activeKind);
|
||||
});
|
||||
}
|
||||
|
||||
void load(activeKind);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
</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) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
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())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const displayName = fullName || card.login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = header.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login;
|
||||
|
||||
body.innerHTML = `
|
||||
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
|
||||
|
||||
<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: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||
<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>
|
||||
|
||||
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
|
||||
|
||||
<div class="user-profile-channel-metrics">
|
||||
${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 class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login)}</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 ? `
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-add-menu" hidden></div>
|
||||
<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">
|
||||
${addIconHtml()}
|
||||
</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>
|
||||
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</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 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>`;
|
||||
</div>` : ''}`;
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
avatarSlot?.append(renderUserAvatar({
|
||||
|
||||
@@ -1595,13 +1595,13 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getMessageLikes(message, limit = 1000) {
|
||||
async getMessageLikes(message) {
|
||||
const normalizedMessage = {
|
||||
blockchainName: String(message?.blockchainName || '').trim(),
|
||||
blockNumber: Number(message?.blockNumber),
|
||||
blockHash: String(message?.blockHash || '').trim(),
|
||||
};
|
||||
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit });
|
||||
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage });
|
||||
if (response.status !== 200) throw opError('GetMessageLikes', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
@@ -3056,6 +3056,56 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async setChannelReadState({
|
||||
login,
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
readCount,
|
||||
timeMs,
|
||||
storagePwd,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanOwnerBch = String(ownerBlockchainName || '').trim();
|
||||
const cleanChannelName = String(channelName || '').trim();
|
||||
const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0)));
|
||||
const cleanTimeMs = Math.trunc(Number(timeMs));
|
||||
if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) {
|
||||
throw new Error('Не переданы login/ownerBlockchainName/channelName');
|
||||
}
|
||||
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) {
|
||||
throw new Error('Не передан корректный timeMs');
|
||||
}
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.');
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
||||
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
||||
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
||||
|
||||
const preimage = [
|
||||
'SHiNe/ChannelReadState:',
|
||||
escapeUserSettingPart(cleanLogin),
|
||||
escapeUserSettingPart(cleanOwnerBch),
|
||||
escapeUserSettingPart(cleanChannelName),
|
||||
String(cleanTimeMs),
|
||||
String(cleanReadCount),
|
||||
].join('|');
|
||||
const signature = await signBase64(privateKey, preimage);
|
||||
|
||||
const response = await this.ws.request('SetChannelReadState', {
|
||||
login: cleanLogin,
|
||||
owner_bch_name: cleanOwnerBch,
|
||||
channel_name: cleanChannelName,
|
||||
read_count: cleanReadCount,
|
||||
time_ms: cleanTimeMs,
|
||||
client_key: clientKey,
|
||||
signature,
|
||||
});
|
||||
if (response.status !== 200) throw opError('SetChannelReadState', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async upsertUserSetting({
|
||||
login,
|
||||
settingType,
|
||||
|
||||
@@ -696,6 +696,9 @@
|
||||
.channel-like-popup {
|
||||
position: fixed;
|
||||
z-index: 1601;
|
||||
left: 50%;
|
||||
top: clamp(72px, 18vh, 180px);
|
||||
transform: translateX(-50%);
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
|
||||
@@ -1805,3 +1805,165 @@
|
||||
.profiles-actions { margin-top: 4px; }
|
||||
|
||||
.profiles-close-all { margin-top: 4px; }
|
||||
|
||||
/* ===== 2026-09-09: profile layout — identity, social row, about card ===== */
|
||||
.user-profile-identity {
|
||||
width: min(88%, 340px);
|
||||
margin: 12px auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-profile-identity .user-profile-full-name {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: rgba(244, 248, 255, 0.96);
|
||||
font-size: clamp(17px, 4.8vw, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-profile-login {
|
||||
margin-top: 4px;
|
||||
color: rgba(199, 211, 229, 0.62);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-profile-social-metrics {
|
||||
width: min(92%, 360px);
|
||||
margin: 22px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-profile-social-metrics .user-profile-metric {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
gap: 5px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.user-profile-social-metrics .user-profile-metric-circle,
|
||||
.user-profile-metric-value-combined {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: rgba(240, 246, 255, 0.95);
|
||||
font-size: clamp(17px, 4.7vw, 20px);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-profile-social-metrics .user-profile-metric-label {
|
||||
position: static;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
transform: none;
|
||||
color: rgba(203, 216, 236, 0.72);
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-profile-about-card {
|
||||
width: min(88%, 344px);
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.user-profile-about-title {
|
||||
margin: 0 0 7px 3px;
|
||||
color: rgba(224, 233, 247, 0.82);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-profile-about-field {
|
||||
min-height: 68px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(210, 220, 234, 0.14);
|
||||
border-radius: 14px;
|
||||
background: rgba(162, 172, 186, 0.16);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: rgba(236, 242, 251, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-profile-about-field.is-empty {
|
||||
color: rgba(199, 210, 226, 0.46);
|
||||
}
|
||||
|
||||
.user-profile-detail-links.user-profile-detail-links--below-about {
|
||||
width: min(88%, 344px);
|
||||
margin: 10px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-profile-detail-links--below-about > :first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.user-profile-detail-links--below-about > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.user-profile-detail-links--below-about button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.profile-list-tabs {
|
||||
width: min(92%, 360px);
|
||||
margin: 4px auto 10px;
|
||||
padding: 3px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
border: 1px solid rgba(205, 221, 244, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(14, 25, 43, 0.24);
|
||||
}
|
||||
|
||||
.profile-list-tab {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: rgba(207, 219, 237, 0.72);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-list-tab.is-active {
|
||||
background: rgba(156, 177, 204, 0.18);
|
||||
color: rgba(246, 249, 255, 0.97);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.user-profile-identity { margin-top: 8px; }
|
||||
.user-profile-social-metrics { margin-top: 16px; }
|
||||
.user-profile-about-card { margin-top: 15px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user