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