SHA256
Compare commits
12
Commits
main
...
dorabotka-ui
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
|||||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||||
deploy/backup/archive/**
|
deploy/backup/archive/**
|
||||||
!deploy/backup/archive/.gitkeep
|
!deploy/backup/archive/.gitkeep
|
||||||
|
SHiNE-bundle-*.zip
|
||||||
|
|
||||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||||
.agents/
|
.agents/
|
||||||
|
|||||||
+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,298 @@
|
|||||||
|
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 Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT channel_root_block_number
|
||||||
|
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.getInt("channel_root_block_number") : 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;
|
||||||
|
|||||||
+12
@@ -75,18 +75,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
|||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||||
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_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;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_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_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;
|
||||||
@@ -208,9 +214,12 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
|
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||||
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("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()),
|
||||||
@@ -299,9 +308,12 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
|
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||||
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("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) {
|
||||||
|
|||||||
+8
-33
@@ -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 {
|
||||||
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
|
boolean bounded = limit > 0;
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
""" + order + " LIMIT ?";
|
""" + order + (bounded ? " LIMIT ?" : "");
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
ps.setInt(9, lineCode);
|
ps.setInt(9, lineCode);
|
||||||
ps.setInt(10, limit);
|
if (bounded) ps.setInt(10, limit);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
+6
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
// limit is optional. null/0 means "return the whole channel".
|
||||||
if (limit <= 0 || limit > 1000) {
|
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||||
|
if (limit < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
if (!asc) {
|
if (!asc) {
|
||||||
java.util.Collections.reverse(posts);
|
java.util.Collections.reverse(posts);
|
||||||
}
|
}
|
||||||
if (posts.size() > limit) {
|
if (limit > 0 && posts.size() > limit) {
|
||||||
posts = new ArrayList<>(posts.subList(0, limit));
|
posts = new ArrayList<>(posts.subList(0, limit));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||||
|
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||||
|
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||||
|
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||||
|
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] blockHash;
|
||||||
|
try {
|
||||||
|
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||||
|
}
|
||||||
|
if (blockHash == null || blockHash.length == 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
SELECT rs.from_login,
|
||||||
|
COALESCE(ups.first_name, '') AS first_name,
|
||||||
|
COALESCE(ups.last_name, '') AS last_name,
|
||||||
|
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||||
|
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||||
|
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||||
|
FROM reactions_state rs
|
||||||
|
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||||
|
WHERE rs.reaction_type = 1
|
||||||
|
AND rs.last_sub_type = 1
|
||||||
|
AND rs.to_bch_name = ?
|
||||||
|
AND rs.to_block_number = ?
|
||||||
|
AND rs.to_block_hash = ?
|
||||||
|
ORDER BY LOWER(rs.from_login)
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, message.getBlockchainName().trim());
|
||||||
|
ps.setInt(2, message.getBlockNumber());
|
||||||
|
ps.setBytes(3, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||||
|
user.setLogin(rs.getString("from_login"));
|
||||||
|
user.setFirstName(rs.getString("first_name"));
|
||||||
|
user.setLastName(rs.getString("last_name"));
|
||||||
|
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||||
|
|
||||||
|
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||||
|
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||||
|
if (isOfficial && isShining) shining.add(user);
|
||||||
|
else if (isOfficial) official.add(user);
|
||||||
|
else others.add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setShining(shining);
|
||||||
|
resp.setOfficial(official);
|
||||||
|
resp.setOthers(others);
|
||||||
|
resp.setTotal(shining.size() + official.size() + others.size());
|
||||||
|
resp.setTruncated(false);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetMessageLikes failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
|
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||||
|
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||||
|
r.setOp(req.getOp());
|
||||||
|
r.setRequestId(req.getRequestId());
|
||||||
|
r.setStatus(WireCodes.Status.OK);
|
||||||
|
r.setLogin(login);
|
||||||
|
r.setDmUnreadCount(s.dmUnreadCount());
|
||||||
|
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||||
|
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||||
|
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||||
|
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||||
|
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||||
|
return r;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetUserCounters failed for {}", login, e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4
@@ -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;
|
||||||
@@ -81,11 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setChannelRoot(rootRef);
|
channelRef.setChannelRoot(rootRef);
|
||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||||
|
row.setMessagesCount(messagesCount);
|
||||||
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.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||||
|
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) {
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
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);
|
||||||
|
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404,
|
||||||
|
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never persist a watermark beyond the number of messages that really exists.
|
||||||
|
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||||
|
// and could make all future unread counters impossible to clear correctly.
|
||||||
|
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||||
|
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||||
|
|
||||||
|
result = dao.upsertSignedReadIfNewer(c,
|
||||||
|
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||||
|
effectiveReadCount, 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("|", "\\|");
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||||
|
import shine.db.dao.ChannelReadStateDAO;
|
||||||
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
|
||||||
|
* It intentionally uses current DB state today; later it can read one materialized row/cache
|
||||||
|
* without changing API or UI.
|
||||||
|
*/
|
||||||
|
public final class UserCountersSupport {
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private UserCountersSupport() {}
|
||||||
|
|
||||||
|
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
|
||||||
|
long connectionsUnreadCount, long eventsUnreadCount) {
|
||||||
|
public long notificationsUnreadCount() {
|
||||||
|
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Snapshot calculate(Connection c, String login) throws Exception {
|
||||||
|
long dm = countDmUnread(c, login);
|
||||||
|
long channels = countChannelsUnread(c, login);
|
||||||
|
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
|
||||||
|
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
|
||||||
|
long repliesSeen = seen.getSeenAt(c, login, "replies");
|
||||||
|
long connectionsSeen = seen.getSeenAt(c, login, "connections");
|
||||||
|
long eventsSeen = seen.getSeenAt(c, login, "events");
|
||||||
|
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
|
||||||
|
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
|
||||||
|
long events = notifications.countUnseen(c, login, "event", eventsSeen);
|
||||||
|
return new Snapshot(dm, channels, replies, connections, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Snapshot calculate(String login) throws Exception {
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
return calculate(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void pushChanged(String login) {
|
||||||
|
if (login == null || login.isBlank()) return;
|
||||||
|
try {
|
||||||
|
Snapshot s = calculate(login);
|
||||||
|
ObjectNode payload = toPayload(login, s);
|
||||||
|
String eventId = "user-counters-" + System.currentTimeMillis();
|
||||||
|
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||||
|
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Counter push is best-effort. The client can always recover with GetUserCounters.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObjectNode toPayload(String login, Snapshot s) {
|
||||||
|
ObjectNode p = MAPPER.createObjectNode();
|
||||||
|
p.put("login", login);
|
||||||
|
p.put("dmUnreadCount", s.dmUnreadCount());
|
||||||
|
p.put("channelsUnreadCount", s.channelsUnreadCount());
|
||||||
|
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
|
||||||
|
ObjectNode n = p.putObject("notifications");
|
||||||
|
n.put("replies", s.repliesUnreadCount());
|
||||||
|
n.put("connections", s.connectionsUnreadCount());
|
||||||
|
n.put("events", s.eventsUnreadCount());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long countDmUnread(Connection c, String login) throws Exception {
|
||||||
|
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||||
|
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Request extends Net_Request {
|
||||||
|
private MessageSelector message;
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
public MessageSelector getMessage() { return message; }
|
||||||
|
public void setMessage(MessageSelector message) { this.message = message; }
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
|
||||||
|
public static class MessageSelector {
|
||||||
|
private String blockchainName;
|
||||||
|
private Integer blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public Integer getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Response extends Net_Response {
|
||||||
|
private List<UserItem> shining = new ArrayList<>();
|
||||||
|
private List<UserItem> official = new ArrayList<>();
|
||||||
|
private List<UserItem> others = new ArrayList<>();
|
||||||
|
private int total;
|
||||||
|
private boolean truncated;
|
||||||
|
|
||||||
|
public List<UserItem> getShining() { return shining; }
|
||||||
|
public void setShining(List<UserItem> shining) { this.shining = shining; }
|
||||||
|
public List<UserItem> getOfficial() { return official; }
|
||||||
|
public void setOfficial(List<UserItem> official) { this.official = official; }
|
||||||
|
public List<UserItem> getOthers() { return others; }
|
||||||
|
public void setOthers(List<UserItem> others) { this.others = others; }
|
||||||
|
public int getTotal() { return total; }
|
||||||
|
public void setTotal(int total) { this.total = total; }
|
||||||
|
public boolean isTruncated() { return truncated; }
|
||||||
|
public void setTruncated(boolean truncated) { this.truncated = truncated; }
|
||||||
|
|
||||||
|
public static class UserItem {
|
||||||
|
private String login;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String avatarAr;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
public String getAvatarAr() { return avatarAr; }
|
||||||
|
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Lightweight authenticated request for bottom-toolbar counters. */
|
||||||
|
public class Net_GetUserCounters_Request extends Net_Request {
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
|
||||||
|
public class Net_GetUserCounters_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private long dmUnreadCount;
|
||||||
|
private long channelsUnreadCount;
|
||||||
|
private long notificationsUnreadCount;
|
||||||
|
private long notificationRepliesUnreadCount;
|
||||||
|
private long notificationConnectionsUnreadCount;
|
||||||
|
private long notificationEventsUnreadCount;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public long getDmUnreadCount() { return dmUnreadCount; }
|
||||||
|
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
|
||||||
|
public long getChannelsUnreadCount() { return channelsUnreadCount; }
|
||||||
|
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
|
||||||
|
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
|
||||||
|
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
|
||||||
|
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
|
||||||
|
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
|
||||||
|
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
|
||||||
|
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
|
||||||
|
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
|
||||||
|
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
|
||||||
|
}
|
||||||
+8
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public static class ChannelSummary {
|
public static class ChannelSummary {
|
||||||
private ChannelRef channel;
|
private ChannelRef channel;
|
||||||
private int messagesCount;
|
private int messagesCount;
|
||||||
|
private long readCount;
|
||||||
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; }
|
||||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public int getMessagesCount() { return messagesCount; }
|
public int getMessagesCount() { return messagesCount; }
|
||||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||||
|
|
||||||
|
public long getReadCount() { return readCount; }
|
||||||
|
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||||
|
|
||||||
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; }
|
||||||
|
}
|
||||||
+2
-1
@@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
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.notifications.entyties.*;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
|
||||||
|
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;
|
||||||
@@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle
|
|||||||
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
||||||
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
||||||
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
||||||
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
|
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r;
|
||||||
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
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.channels.UserCountersSupport;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||||
|
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||||
|
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||||
}
|
}
|
||||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||||
|
|||||||
+9
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
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.channels.UserCountersSupport;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairStatus.applied()) {
|
if (pairStatus.applied()) {
|
||||||
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||||
|
|
||||||
|
// SendMessagePair is also the local-server delivery path. Without this push,
|
||||||
|
// a recipient on the same access server receives the DM but the toolbar
|
||||||
|
// counter stays stale until the next explicit GetUserCounters refresh.
|
||||||
|
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||||
|
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
|
||||||
|
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.0
|
client.version=1.12.12
|
||||||
server.version=1.10.0
|
server.version=1.10.4
|
||||||
|
|||||||
+18
-1
@@ -123,6 +123,23 @@ find_offline_gradle_zip() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
local extracted_dir=""
|
||||||
|
if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then
|
||||||
|
extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)"
|
||||||
|
fi
|
||||||
|
if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then
|
||||||
|
local generated_zip="$TMP/gradle-offline.zip"
|
||||||
|
(
|
||||||
|
cd "$(dirname -- "$extracted_dir")"
|
||||||
|
rm -f -- "$generated_zip"
|
||||||
|
zip -qr "$generated_zip" "gradle-8.14"
|
||||||
|
)
|
||||||
|
if [[ -f "$generated_zip" ]]; then
|
||||||
|
printf '%s\n' "$generated_zip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
|||||||
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||||
else
|
else
|
||||||
echo "ERROR: offline Gradle zip not found." >&2
|
echo "ERROR: offline Gradle zip not found." >&2
|
||||||
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
echo "Place it at ./offline/gradle-offline.zip, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&2
|
||||||
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||||
exit 4
|
exit 4
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -14,19 +14,24 @@
|
|||||||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||||
предки, фокус-сообщение, потомки.
|
предки, фокус-сообщение, потомки.
|
||||||
|
|
||||||
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению,
|
||||||
|
сгруппированные для UI по статусу профиля.
|
||||||
|
|
||||||
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||||
|
|
||||||
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||||
|
|
||||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
|
||||||
|
|
||||||
|
8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||||
|
|
||||||
|
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||||
|
|
||||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
>
|
>
|
||||||
> `unreadCount` для канала считается по `user_settings`.
|
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,6 +112,9 @@
|
|||||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||||
},
|
},
|
||||||
"messagesCount": 90,
|
"messagesCount": 90,
|
||||||
|
"readCount": 0,
|
||||||
|
"unreadCount": 0,
|
||||||
|
"readStateInitialized": false,
|
||||||
"lastMessage": {
|
"lastMessage": {
|
||||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||||
"text": "актуальный текст",
|
"text": "актуальный текст",
|
||||||
@@ -141,6 +149,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||||||
|
|
||||||
### Response (success)
|
### Response (success)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -266,7 +276,63 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) GetPersonalDiary
|
## 4) GetMessageLikes
|
||||||
|
|
||||||
|
Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
|
||||||
|
|
||||||
|
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
|
||||||
|
- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
|
||||||
|
- Пользователи группируются по состоянию профиля:
|
||||||
|
- `shining` — `account_role=primary` и `shine_status=shining`;
|
||||||
|
- `official` — `account_role=primary`, но без `shine_status=shining`;
|
||||||
|
- `others` — остальные пользователи.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageLikes",
|
||||||
|
"requestId": "req-4",
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"blockchainName": "bob-001",
|
||||||
|
"blockNumber": 140,
|
||||||
|
"blockHash": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (success)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageLikes",
|
||||||
|
"requestId": "req-4",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"shining": [
|
||||||
|
{ "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." }
|
||||||
|
],
|
||||||
|
"official": [],
|
||||||
|
"others": [
|
||||||
|
{ "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" }
|
||||||
|
],
|
||||||
|
"total": 2,
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение.
|
||||||
|
- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока.
|
||||||
|
- `internal_error` — внутренняя ошибка чтения.
|
||||||
|
|
||||||
|
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) GetPersonalDiary
|
||||||
|
|
||||||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||||
|
|
||||||
@@ -289,7 +355,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) GetChannelsCounters
|
## 6) GetChannelsCounters
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
```json
|
```json
|
||||||
@@ -321,7 +387,69 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5) 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
|
||||||
@@ -361,7 +489,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6) GetGroupDialog
|
## 9) GetGroupDialog
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
```json
|
```json
|
||||||
@@ -414,8 +542,10 @@
|
|||||||
- `user_not_found`
|
- `user_not_found`
|
||||||
- `channel_not_found`
|
- `channel_not_found`
|
||||||
- `message_not_found`
|
- `message_not_found`
|
||||||
- `limit_too_large`
|
- `bad_limit`
|
||||||
- `channel_name_already_exists`
|
- `channel_name_already_exists`
|
||||||
|
- `CHANNEL_NOT_FOLLOWED`
|
||||||
|
- `CHANNEL_NOT_FOUND`
|
||||||
- `internal_error`
|
- `internal_error`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
- `ListSubscriptionsFeed` — экран списка каналов.
|
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||||
- `GetChannelMessages` — сообщения конкретного канала.
|
- `GetChannelMessages` — сообщения конкретного канала.
|
||||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||||
|
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
|
||||||
|
- `SetChannelReadState` — подписанный watermark чтения канала.
|
||||||
|
|
||||||
2. **UI вкладки Каналы**:
|
2. **UI вкладки Каналы**:
|
||||||
- при открытии пытается загрузить реальный feed с сервера;
|
- при открытии пытается загрузить реальный feed с сервера;
|
||||||
@@ -33,12 +35,16 @@
|
|||||||
1. Вызвать `ListSubscriptionsFeed`.
|
1. Вызвать `ListSubscriptionsFeed`.
|
||||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||||
|
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. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`.
|
||||||
|
5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`.
|
||||||
|
6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,6 +97,38 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 3.4 GetMessageLikes
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMessageLikes",
|
||||||
|
"requestId": "debug-likes-1",
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"blockchainName": "TestUser1-001",
|
||||||
|
"blockNumber": 123,
|
||||||
|
"blockHash": "<hash-from-GetChannelMessages>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) Что смотреть в ответах
|
## 4) Что смотреть в ответах
|
||||||
@@ -101,6 +139,8 @@
|
|||||||
- у каждой записи есть:
|
- у каждой записи есть:
|
||||||
- `channel.channelRoot.blockNumber`,
|
- `channel.channelRoot.blockNumber`,
|
||||||
- `messagesCount`,
|
- `messagesCount`,
|
||||||
|
- `unreadCount`,
|
||||||
|
- `readStateInitialized`,
|
||||||
- `lastMessage` (может быть null, если сообщений нет).
|
- `lastMessage` (может быть null, если сообщений нет).
|
||||||
|
|
||||||
### GetChannelMessages
|
### GetChannelMessages
|
||||||
@@ -115,6 +155,16 @@
|
|||||||
- у узлов должны быть версии и счетчики.
|
- у узлов должны быть версии и счетчики.
|
||||||
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
|
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
|
||||||
|
|
||||||
|
### GetMessageLikes
|
||||||
|
- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей.
|
||||||
|
- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`.
|
||||||
|
- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации.
|
||||||
|
|
||||||
|
### SetChannelReadState
|
||||||
|
- `payload.read_count` — сохраненная позиция чтения.
|
||||||
|
- `payload.unread_count` — пересчитанный unread для канала.
|
||||||
|
- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция.
|
||||||
|
|
||||||
### Важно по совместимости
|
### Важно по совместимости
|
||||||
- `rawBlockB64` добавлен только в `GetMessageThread`.
|
- `rawBlockB64` добавлен только в `GetMessageThread`.
|
||||||
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
|
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
|
||||||
|
|||||||
@@ -46,8 +46,10 @@
|
|||||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||||
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
||||||
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
|
| `GetMessageThread` | `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` | запись параметра пользователя |
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# User counters API
|
||||||
|
|
||||||
|
## GetUserCounters
|
||||||
|
Authenticated lightweight WebSocket request used by the bottom toolbar.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{"op":"GetUserCounters","requestId":"...","payload":{}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"login": "alice",
|
||||||
|
"dmUnreadCount": 7,
|
||||||
|
"channelsUnreadCount": 12,
|
||||||
|
"notificationsUnreadCount": 5,
|
||||||
|
"notificationRepliesUnreadCount": 2,
|
||||||
|
"notificationConnectionsUnreadCount": 1,
|
||||||
|
"notificationEventsUnreadCount": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later.
|
||||||
|
|
||||||
|
## UserCountersChanged
|
||||||
|
Server push event for every active session of the user. It lets the toolbar update without polling.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op":"UserCountersChanged",
|
||||||
|
"event":true,
|
||||||
|
"payload":{
|
||||||
|
"login":"alice",
|
||||||
|
"dmUnreadCount":7,
|
||||||
|
"channelsUnreadCount":12,
|
||||||
|
"notificationsUnreadCount":5,
|
||||||
|
"notifications":{"replies":2,"connections":1,"events":2}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement.
|
||||||
|
|
||||||
|
## Channel read behaviour
|
||||||
|
- Opening a subscribed channel does **not** mark all messages read.
|
||||||
|
- The UI advances readCount only when message cards cross the viewport read threshold while scrolling.
|
||||||
|
- Writes are debounced; failed writes are shown to the user and retried.
|
||||||
|
- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time.
|
||||||
@@ -131,6 +131,12 @@ dm_dialog_state хранит:
|
|||||||
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
||||||
историю у своего единственного access-сервера.
|
историю у своего единственного access-сервера.
|
||||||
|
|
||||||
|
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
|
||||||
|
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
|
||||||
|
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
|
||||||
|
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
|
||||||
|
видимого чтения/receipt отмечает его прочитанным.
|
||||||
|
|
||||||
## 9. Подтверждения прочтения
|
## 9. Подтверждения прочтения
|
||||||
|
|
||||||
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||||
|
|||||||
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
|
|||||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||||
|
|
||||||
|
|
||||||
|
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
|
||||||
|
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
|
||||||
|
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
|
||||||
|
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
|
||||||
|
|
||||||
## 9. Контент типов `5/6`
|
## 9. Контент типов `5/6`
|
||||||
|
|
||||||
Типы:
|
Типы:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
# Личный дневник — временно отключён в UI
|
||||||
|
|
||||||
|
Статус: **закомментировано / скрыто из интерфейса**.
|
||||||
|
|
||||||
|
Личный дневник SHiNE не удалён из протокола и серверной части. На текущем этапе он только перестал отображаться пользователю в списке каналов.
|
||||||
|
|
||||||
|
## Что это за функция
|
||||||
|
|
||||||
|
«Дневник» — виртуальный персональный канал пользователя. Он формируется не как обычный канал с отдельной цепочкой постов, а собирается сервером из `STATUS_ACTION` записей пользователя.
|
||||||
|
|
||||||
|
Основной read API:
|
||||||
|
|
||||||
|
- `GetPersonalDiary`
|
||||||
|
|
||||||
|
Связанная документация:
|
||||||
|
|
||||||
|
- `docs/API/06_Channels_Read_API.md` — раздел `GetPersonalDiary`;
|
||||||
|
- `docs/API/09_Operations_Index.md` — операция `GetPersonalDiary`;
|
||||||
|
- `docs/Blockchain/15_STATUS_ACTION_Blocks.md` — действия, из которых собирается дневник;
|
||||||
|
- `docs/Blockchain/CHANGELOG.md` — история добавления функции.
|
||||||
|
|
||||||
|
## Что отключено сейчас
|
||||||
|
|
||||||
|
В `shine-UI/js/pages/channels-list.js` отключён запрос `authService.getPersonalDiary(...)` при построении списка каналов.
|
||||||
|
|
||||||
|
Код оставлен рядом в комментариях, а в `mapApiFeed(...)` передаётся `diaryPayload = null`. Благодаря этому карточка «Дневник» не появляется в разделе каналов.
|
||||||
|
|
||||||
|
## Что намеренно НЕ удалено
|
||||||
|
|
||||||
|
Чтобы не ломать обратную совместимость и сохранить возможность вернуть функцию позже, оставлены:
|
||||||
|
|
||||||
|
- серверная операция `GetPersonalDiary`;
|
||||||
|
- `authService.getPersonalDiary(...)`;
|
||||||
|
- обработка diary-route в `channel-view.js`;
|
||||||
|
- форматы `STATUS_ACTION`;
|
||||||
|
- чтение старых типов сообщений и действий.
|
||||||
|
|
||||||
|
То есть функция сохранена технически, но скрыта из обычной навигации.
|
||||||
|
|
||||||
|
## Как вернуть дневник
|
||||||
|
|
||||||
|
В `shine-UI/js/pages/channels-list.js` вернуть получение `diaryPayload`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
let diaryPayload = null;
|
||||||
|
try {
|
||||||
|
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||||
|
} catch {
|
||||||
|
diaryPayload = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
и убрать временное:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const diaryPayload = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого `mapApiFeed(...)` снова сможет добавить виртуальную карточку дневника в список каналов.
|
||||||
|
|
||||||
|
## Связанные типы контента
|
||||||
|
|
||||||
|
В интерфейсе создания новой записи канала сейчас доступны только:
|
||||||
|
|
||||||
|
- `POST (10)` — Пост;
|
||||||
|
- `TEXT_ENTRYPOINT (100)` — Оглавление канала.
|
||||||
|
|
||||||
|
Варианты `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)` и `TEXT_COURSE (130)` убраны только из формы создания новой записи. Их константы и обработчики чтения сохранены для совместимости со старыми данными.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="14" y1="10" x2="52" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#56C7FF"/>
|
||||||
|
<stop offset="0.48" stop-color="#1687F8"/>
|
||||||
|
<stop offset="1" stop-color="#075EE8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ring" x1="10" y1="8" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#A8EDFF"/>
|
||||||
|
<stop offset="0.5" stop-color="#4BC7FF"/>
|
||||||
|
<stop offset="1" stop-color="#2473FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx="32" cy="32" r="29" fill="url(#bg)"/>
|
||||||
|
<circle cx="32" cy="32" r="28" stroke="url(#ring)" stroke-width="2"/>
|
||||||
|
<circle cx="32" cy="32" r="24.5" stroke="#BCEEFF" stroke-opacity="0.72" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<path d="M19.5 32.7L27.7 40.9L45 23.6"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 984 B |
@@ -20,16 +20,68 @@ function iconHtml(item) {
|
|||||||
: `<span>${item.icon}</span>`;
|
: `<span>${item.icon}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalUnreadMessages() {
|
function normalizeCounters(payload = {}) {
|
||||||
const chats = Object.values(state.chats || {});
|
const notifications = payload?.notifications || {};
|
||||||
let total = 0;
|
const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? (
|
||||||
chats.forEach((messages) => {
|
Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0)
|
||||||
if (!Array.isArray(messages)) return;
|
)) || 0);
|
||||||
messages.forEach((msg) => {
|
return {
|
||||||
if (msg?.from === 'in' && msg?.unread) total += 1;
|
dmUnreadCount: Math.max(0, Number(payload?.dmUnreadCount || 0) || 0),
|
||||||
|
channelsUnreadCount: Math.max(0, Number(payload?.channelsUnreadCount || 0) || 0),
|
||||||
|
notificationsUnreadCount: notificationTotal,
|
||||||
|
notifications: {
|
||||||
|
replies: Math.max(0, Number(notifications.replies ?? payload?.notificationRepliesUnreadCount ?? 0) || 0),
|
||||||
|
connections: Math.max(0, Number(notifications.connections ?? payload?.notificationConnectionsUnreadCount ?? 0) || 0),
|
||||||
|
events: Math.max(0, Number(notifications.events ?? payload?.notificationEventsUnreadCount ?? 0) || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCounterState(payload = {}) {
|
||||||
|
state.userCounters = normalizeCounters(payload);
|
||||||
|
state.notificationUnreadTotal = state.userCounters.notificationsUnreadCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||||
|
if (!btn) return;
|
||||||
|
let badge = btn.querySelector('.toolbar-unread-badge');
|
||||||
|
if (count <= 0) { badge?.remove(); return; }
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||||
|
btn.append(badge);
|
||||||
|
}
|
||||||
|
badge.textContent = count > 99 ? '99+' : String(count);
|
||||||
|
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCountersToMountedToolbars() {
|
||||||
|
const c = normalizeCounters(state.userCounters);
|
||||||
|
document.querySelectorAll('.toolbar').forEach((toolbar) => {
|
||||||
|
renderBadge(toolbar.querySelector('[data-toolbar-page="messages-list"]'), c.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||||
|
renderBadge(toolbar.querySelector('[data-toolbar-page="channels-list"]'), c.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||||
|
renderBadge(toolbar.querySelector('[data-toolbar-page="notifications-view"]'), c.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let countersPushBound = false;
|
||||||
|
function ensureCountersPushBound() {
|
||||||
|
if (countersPushBound) return;
|
||||||
|
countersPushBound = true;
|
||||||
|
authService.onEvent('UserCountersChanged', (event) => {
|
||||||
|
setCounterState(event?.payload || {});
|
||||||
|
applyCountersToMountedToolbars();
|
||||||
});
|
});
|
||||||
return total;
|
}
|
||||||
|
|
||||||
|
async function refreshUserCounters() {
|
||||||
|
if (!state.session.isAuthorized) return;
|
||||||
|
try {
|
||||||
|
setCounterState(await authService.getUserCounters());
|
||||||
|
applyCountersToMountedToolbars();
|
||||||
|
} catch {
|
||||||
|
// Keep the last known counters; realtime push or the next refresh can recover.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateWithGuestRules(pageId, navigate) {
|
function navigateWithGuestRules(pageId, navigate) {
|
||||||
@@ -65,7 +117,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const root = document.createElement('nav');
|
const root = document.createElement('nav');
|
||||||
root.className = 'toolbar';
|
root.className = 'toolbar';
|
||||||
const active = resolveToolbarActive(currentPageId);
|
const active = resolveToolbarActive(currentPageId);
|
||||||
const unreadTotal = getTotalUnreadMessages();
|
ensureCountersPushBound();
|
||||||
|
const counters = normalizeCounters(state.userCounters);
|
||||||
|
|
||||||
ITEMS.forEach((item) => {
|
ITEMS.forEach((item) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
@@ -92,21 +145,9 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
} else {
|
} else {
|
||||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||||
}
|
}
|
||||||
if (isMessages && unreadTotal > 0) {
|
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||||
const badge = document.createElement('span');
|
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||||
badge.className = 'toolbar-unread-badge';
|
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||||
badge.textContent = unreadTotal > 99 ? '99+' : String(unreadTotal);
|
|
||||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
|
||||||
btn.append(badge);
|
|
||||||
}
|
|
||||||
if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) {
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'toolbar-unread-badge notification-toolbar-badge';
|
|
||||||
const n = Number(state.notificationUnreadTotal || 0);
|
|
||||||
badge.textContent = n > 99 ? '99+' : String(n);
|
|
||||||
badge.setAttribute('aria-label', `Новых уведомлений: ${n}`);
|
|
||||||
btn.append(badge);
|
|
||||||
}
|
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
btn.addEventListener('click', () => navigate('channels-list'));
|
btn.addEventListener('click', () => navigate('channels-list'));
|
||||||
} else {
|
} else {
|
||||||
@@ -115,19 +156,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
root.append(btn);
|
root.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
|
if (state.session.isAuthorized) void refreshUserCounters();
|
||||||
void authService.getNotifications(true).then((payload) => {
|
|
||||||
const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0);
|
|
||||||
state.notificationUnreadTotal = total;
|
|
||||||
const btn = root.querySelector('[data-toolbar-page="notifications-view"]');
|
|
||||||
if (!btn) return;
|
|
||||||
let badge = btn.querySelector('.notification-toolbar-badge');
|
|
||||||
if (total <= 0) { badge?.remove(); return; }
|
|
||||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
|
||||||
badge.textContent = total > 99 ? '99+' : String(total);
|
|
||||||
badge.setAttribute('aria-label', `Новых уведомлений: ${total}`);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const footer = document.createElement('div');
|
screen.append(card);
|
||||||
footer.className = 'meta-muted screen-footer';
|
|
||||||
footer.textContent = 'О канале (channel-about-view)';
|
|
||||||
|
|
||||||
screen.append(card, footer);
|
|
||||||
|
|
||||||
const renderContent = (channel) => {
|
const renderContent = (channel) => {
|
||||||
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||||
|
|||||||
@@ -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,21 +279,32 @@ 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,
|
||||||
|
onPersistError = null,
|
||||||
|
onPersistSuccess = null,
|
||||||
}) {
|
}) {
|
||||||
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 missingWriteParts = [];
|
||||||
|
if (!cleanOwnerBlockchainName) missingWriteParts.push('ownerBlockchainName');
|
||||||
|
if (!cleanChannelName) missingWriteParts.push('channelName');
|
||||||
|
if (!login) missingWriteParts.push('login');
|
||||||
|
if (!storagePwd) missingWriteParts.push('storagePwd');
|
||||||
|
const canWrite = missingWriteParts.length === 0;
|
||||||
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 unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
||||||
|
|
||||||
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;
|
||||||
@@ -325,7 +329,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;
|
||||||
@@ -333,17 +337,28 @@ function createChannelReadTracker({
|
|||||||
|
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try {
|
try {
|
||||||
await authService.upsertUserSetting({
|
const persisted = 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;
|
const serverReadCount = Math.max(0, Number(persisted?.read_count ?? next));
|
||||||
} catch {
|
const serverUnreadCount = Math.max(0, Number(persisted?.unread_count ?? Math.max(0, safeMessagesCount - serverReadCount)));
|
||||||
|
persistedSeenCount = Math.max(persistedSeenCount, Math.min(serverReadCount, safeMessagesCount));
|
||||||
|
desiredSeenCount = Math.max(desiredSeenCount, persistedSeenCount);
|
||||||
|
initialPersistPending = false;
|
||||||
|
if (typeof onPersistSuccess === 'function') {
|
||||||
|
onPersistSuccess({
|
||||||
|
readCount: persistedSeenCount,
|
||||||
|
unreadCount: serverUnreadCount,
|
||||||
|
applied: persisted?.applied !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
|
||||||
queueFlush(800);
|
queueFlush(800);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
@@ -353,17 +368,24 @@ function createChannelReadTracker({
|
|||||||
const collectSeenCount = () => {
|
const collectSeenCount = () => {
|
||||||
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
||||||
if (!cards.length) return safeInitialSeenCount;
|
if (!cards.length) return safeInitialSeenCount;
|
||||||
if (!unreadLine) return safeMessagesCount;
|
// Absence of the divider must never imply that unseen messages are read.
|
||||||
|
// Use only the highest message card that actually crossed the viewport.
|
||||||
|
|
||||||
|
|
||||||
const root = getChannelScrollRoot();
|
const root = getChannelScrollRoot();
|
||||||
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
|
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
|
||||||
|
const visibleBottom = Math.max(thresholdTop, viewportHeight - 24);
|
||||||
let seen = safeInitialSeenCount;
|
let seen = safeInitialSeenCount;
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const localNumber = Number(card.dataset.localNumber || 0);
|
const localNumber = Number(card.dataset.localNumber || 0);
|
||||||
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
|
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
|
||||||
const rect = card.getBoundingClientRect();
|
const rect = card.getBoundingClientRect();
|
||||||
if (rect.top > thresholdTop + 1) break;
|
if (rect.top > visibleBottom) break;
|
||||||
|
if (rect.bottom < 0) {
|
||||||
|
seen = Math.max(seen, localNumber);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen = Math.max(seen, localNumber);
|
seen = Math.max(seen, localNumber);
|
||||||
}
|
}
|
||||||
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
|
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
|
||||||
@@ -382,6 +404,12 @@ function createChannelReadTracker({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!canWrite && (initializeIfMissing || unreadCount > 0)) {
|
||||||
|
const error = new Error(`Нельзя сохранить прочитанность канала: отсутствует ${missingWriteParts.join(', ')}`);
|
||||||
|
console.error('[SHiNE][ChannelReadState]', error);
|
||||||
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: false });
|
||||||
|
}
|
||||||
|
|
||||||
const scrollRoot = getChannelScrollRoot();
|
const scrollRoot = getChannelScrollRoot();
|
||||||
const onScroll = () => measure();
|
const onScroll = () => measure();
|
||||||
const onResize = () => measure();
|
const onResize = () => measure();
|
||||||
@@ -393,10 +421,10 @@ function createChannelReadTracker({
|
|||||||
}
|
}
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
if (canWrite) {
|
// Opening a channel must NOT mark the whole channel as read.
|
||||||
desiredSeenCount = safeMessagesCount;
|
// Only cards actually crossed by the viewport tracker advance desiredSeenCount.
|
||||||
void flush();
|
// 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 = () => {
|
||||||
@@ -1215,9 +1243,6 @@ function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () =>
|
|||||||
<div class="channel-message-tools">
|
<div class="channel-message-tools">
|
||||||
<select id="channel-message-type" class="input channel-message-type-select">
|
<select id="channel-message-type" class="input channel-message-type-select">
|
||||||
<option value="${10}">Пост</option>
|
<option value="${10}">Пост</option>
|
||||||
<option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option>
|
|
||||||
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
|
|
||||||
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option>
|
|
||||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||||
@@ -1458,6 +1483,8 @@ async function loadFromApi(route, channelId) {
|
|||||||
const isAuthorized = !!currentSessionLogin;
|
const isAuthorized = !!currentSessionLogin;
|
||||||
let unreadCount = 0;
|
let unreadCount = 0;
|
||||||
let messagesCount = 0;
|
let messagesCount = 0;
|
||||||
|
let readCount = 0;
|
||||||
|
let readStateInitialized = false;
|
||||||
let cachedFeed = null;
|
let cachedFeed = null;
|
||||||
const ensureFeed = async () => {
|
const ensureFeed = async () => {
|
||||||
if (cachedFeed) return cachedFeed;
|
if (cachedFeed) return cachedFeed;
|
||||||
@@ -1518,9 +1545,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||||
let unreadCount = 0;
|
|
||||||
let messagesCount = 0;
|
|
||||||
|
|
||||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||||
@@ -1572,6 +1596,8 @@ 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);
|
||||||
|
readCount = Number(channel?.readCount || 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),
|
||||||
@@ -1584,7 +1610,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
const payload = await authService.getChannelMessages(selector, null, 'asc', currentSessionLogin);
|
||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
let reverseChannelMissingWarning = '';
|
let reverseChannelMissingWarning = '';
|
||||||
let mergedMessages = [...messages];
|
let mergedMessages = [...messages];
|
||||||
@@ -1619,7 +1645,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
||||||
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
||||||
};
|
};
|
||||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
const reversePayload = await authService.getChannelMessages(reverseSelector, null, 'asc', currentSessionLogin);
|
||||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||||
} else {
|
} else {
|
||||||
@@ -1663,8 +1689,10 @@ async function loadFromApi(route, channelId) {
|
|||||||
reverseChannelMissingWarning,
|
reverseChannelMissingWarning,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
messagesCount,
|
messagesCount,
|
||||||
|
readCount,
|
||||||
isOwnChannel,
|
isOwnChannel,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
readStateInitialized,
|
||||||
selector,
|
selector,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1791,6 +1819,175 @@ function renderChannelMetaEventCard(event) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function likeCategoryCounts(post) {
|
||||||
|
const total = Math.max(0, Number(post?.likesCount || 0));
|
||||||
|
const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
|
||||||
|
const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0)));
|
||||||
|
return {
|
||||||
|
shining,
|
||||||
|
official,
|
||||||
|
all: total,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining' }) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'channel-likes-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<section class="channel-likes-modal" role="dialog" aria-modal="true" aria-label="Кто поставил лайк">
|
||||||
|
<header class="channel-likes-modal__header">
|
||||||
|
<h3>Лайки</h3>
|
||||||
|
<button type="button" class="ui-button channel-likes-modal__close" aria-label="Закрыть">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="channel-likes-tabs" role="tablist">
|
||||||
|
<button type="button" class="ui-button" data-like-tab="shining">Сияющие <span data-like-tab-count="shining"></span></button>
|
||||||
|
<button type="button" class="ui-button" data-like-tab="official">Официальные <span data-like-tab-count="official"></span></button>
|
||||||
|
<button type="button" class="ui-button" data-like-tab="all">Все <span data-like-tab-count="all"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="channel-likes-modal__status">Загрузка...</div>
|
||||||
|
<div class="channel-likes-user-list"></div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
document.body.append(overlay);
|
||||||
|
|
||||||
|
const modal = overlay.querySelector('.channel-likes-modal');
|
||||||
|
const status = overlay.querySelector('.channel-likes-modal__status');
|
||||||
|
const list = overlay.querySelector('.channel-likes-user-list');
|
||||||
|
const close = () => overlay.remove();
|
||||||
|
overlay.querySelector('.channel-likes-modal__close')?.addEventListener('click', close);
|
||||||
|
overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
|
||||||
|
modal?.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
|
||||||
|
let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining';
|
||||||
|
let payload = null;
|
||||||
|
|
||||||
|
const renderTab = () => {
|
||||||
|
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
|
||||||
|
const isActive = button.dataset.likeTab === activeTab;
|
||||||
|
button.classList.toggle('is-active', isActive);
|
||||||
|
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
if (!payload) return;
|
||||||
|
const shiningRows = Array.isArray(payload?.shining) ? payload.shining : [];
|
||||||
|
const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : [];
|
||||||
|
const otherRows = Array.isArray(payload?.others) ? payload.others : [];
|
||||||
|
const rows = activeTab === 'shining'
|
||||||
|
? shiningRows
|
||||||
|
: activeTab === 'official'
|
||||||
|
? [...shiningRows, ...officialOnlyRows]
|
||||||
|
: [...shiningRows, ...officialOnlyRows, ...otherRows];
|
||||||
|
list.innerHTML = '';
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const userButton = document.createElement('button');
|
||||||
|
userButton.type = 'button';
|
||||||
|
userButton.className = 'ui-button card row profile-list-row channel-like-user-row';
|
||||||
|
const avatarRaw = String(row?.avatarAr || '').trim();
|
||||||
|
userButton.append(renderUserAvatar({
|
||||||
|
login: String(row?.login || '').trim() || 'unknown',
|
||||||
|
firstName: String(row?.firstName || ''),
|
||||||
|
lastName: String(row?.lastName || ''),
|
||||||
|
avatar: avatarRaw ? { ar: avatarRaw } : null,
|
||||||
|
size: 'md',
|
||||||
|
}));
|
||||||
|
const firstName = String(row?.firstName || '').trim();
|
||||||
|
const lastName = String(row?.lastName || '').trim();
|
||||||
|
const login = String(row?.login || '').trim();
|
||||||
|
const name = [firstName, lastName].filter(Boolean).join(' ') || login;
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'profile-list-row-text';
|
||||||
|
text.innerHTML = `<b>${escapeHtml(name)}</b><small>${escapeHtml(login)}</small>`;
|
||||||
|
userButton.append(text);
|
||||||
|
userButton.addEventListener('click', () => {
|
||||||
|
close();
|
||||||
|
navigate(makeProfileRoute(login));
|
||||||
|
});
|
||||||
|
list.append(userButton);
|
||||||
|
});
|
||||||
|
status.textContent = rows.length ? '' : 'В этом списке пока никого нет.';
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
activeTab = button.dataset.likeTab;
|
||||||
|
renderTab();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderTab();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
payload = await authService.getMessageLikes(messageRef);
|
||||||
|
if (!overlay.isConnected) return;
|
||||||
|
const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0;
|
||||||
|
const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0;
|
||||||
|
const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0;
|
||||||
|
const tabCounts = {
|
||||||
|
shining: shiningCount,
|
||||||
|
official: shiningCount + officialOnlyCount,
|
||||||
|
all: shiningCount + officialOnlyCount + otherCount,
|
||||||
|
};
|
||||||
|
Object.entries(tabCounts).forEach(([key, value]) => {
|
||||||
|
const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`);
|
||||||
|
if (countEl) countEl.textContent = String(value);
|
||||||
|
});
|
||||||
|
renderTab();
|
||||||
|
} catch (error) {
|
||||||
|
if (!overlay.isConnected) return;
|
||||||
|
status.className = 'channel-likes-modal__status is-error';
|
||||||
|
status.textContent = toUserMessage(error, 'Не удалось загрузить список лайков.');
|
||||||
|
list.innerHTML = '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) {
|
||||||
|
document.querySelectorAll('.channel-like-popup-layer').forEach((el) => el.remove());
|
||||||
|
const counts = likeCategoryCounts(post);
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.className = 'channel-like-popup-layer';
|
||||||
|
layer.innerHTML = `
|
||||||
|
<section class="channel-like-popup" role="dialog" aria-label="Лайки сообщения">
|
||||||
|
<div class="channel-like-popup__title">Лайки</div>
|
||||||
|
<div class="channel-like-popup__counts">
|
||||||
|
<button type="button" class="ui-button channel-like-count" data-like-list="shining"><b>${counts.shining}</b><span>Сияющие</span></button>
|
||||||
|
<button type="button" class="ui-button channel-like-count" data-like-list="official"><b>${counts.official}</b><span>Официальные</span></button>
|
||||||
|
<button type="button" class="ui-button channel-like-count" data-like-list="all"><b>${counts.all}</b><span>Все</span></button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ui-button channel-like-popup__action">${post.reactionState === 'liked' ? 'Убрать свой лайк' : 'Добавить свой лайк'}</button>
|
||||||
|
<button type="button" class="ui-button channel-like-popup__close">Закрыть</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
document.body.append(layer);
|
||||||
|
const popup = layer.querySelector('.channel-like-popup');
|
||||||
|
const width = Math.min(330, Math.max(270, window.innerWidth - 24));
|
||||||
|
popup.style.width = `${width}px`;
|
||||||
|
|
||||||
|
const close = () => layer.remove();
|
||||||
|
layer.addEventListener('click', (event) => { if (event.target === layer) close(); });
|
||||||
|
popup.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
layer.querySelector('.channel-like-popup__close')?.addEventListener('click', close);
|
||||||
|
layer.querySelectorAll('[data-like-list]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const initialTab = button.dataset.likeList;
|
||||||
|
close();
|
||||||
|
openMessageLikesListModal({ navigate, messageRef: post.messageRef, initialTab });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
layer.querySelector('.channel-like-popup__action')?.addEventListener('click', async (event) => {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await onToggleLike(post.messageRef, post.reactionState === 'liked' ? 'unlike' : 'like');
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
button.disabled = false;
|
||||||
|
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderPostCard(post, {
|
function renderPostCard(post, {
|
||||||
navigate,
|
navigate,
|
||||||
selector,
|
selector,
|
||||||
@@ -1977,18 +2174,11 @@ function renderPostCard(post, {
|
|||||||
`;
|
`;
|
||||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
if (!isLiked) {
|
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike });
|
||||||
const ok = window.confirm('Поставить лайк?');
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
await longPressFeel(event.currentTarget, 130);
|
|
||||||
likeButton.disabled = true;
|
|
||||||
setActionTitle(likeButton, 'Лайк...');
|
|
||||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
@@ -2104,9 +2294,10 @@ function renderPostCard(post, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||||
|
const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {};
|
||||||
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
||||||
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
||||||
const readCount = Math.max(0, messagesCount - unreadCount);
|
const readCount = Math.max(0, Math.min(Number(channelData.readCount || 0), messagesCount));
|
||||||
|
|
||||||
if (channelData.reverseChannelMissingWarning) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
@@ -2158,7 +2349,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||||
const unreadLine = document.createElement('div');
|
const unreadLine = document.createElement('div');
|
||||||
unreadLine.className = 'card channel-unread-line';
|
unreadLine.className = 'card channel-unread-line';
|
||||||
unreadLine.textContent = 'Не прочитанные сообщения';
|
unreadLine.textContent = 'Новые сообщения';
|
||||||
feed.append(unreadLine);
|
feed.append(unreadLine);
|
||||||
unreadLineInserted = true;
|
unreadLineInserted = true;
|
||||||
}
|
}
|
||||||
@@ -2209,9 +2400,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
} else if (channelData.isOwnChannel) {
|
} else if (channelData.isOwnChannel) {
|
||||||
screen.append(feed, addMessageButton);
|
screen.append(feed, addMessageButton);
|
||||||
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
||||||
screen.append(actionButton, feed, backButton);
|
screen.append(feed, actionButton);
|
||||||
} else {
|
} else {
|
||||||
screen.append(feed, backButton);
|
screen.append(feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||||
@@ -2223,13 +2414,42 @@ 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,
|
||||||
|
onPersistError: (error, options = {}) => {
|
||||||
|
const detail = String(error?.message || '').trim();
|
||||||
|
const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.';
|
||||||
|
showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`);
|
||||||
|
},
|
||||||
|
onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => {
|
||||||
|
channelData.readCount = persistedReadCount;
|
||||||
|
channelData.unreadCount = persistedUnreadCount;
|
||||||
|
channelData.readStateInitialized = true;
|
||||||
|
|
||||||
|
// The "Новые сообщения" divider is a snapshot of the unread boundary at the
|
||||||
|
// moment this channel view was opened. Persisting read state must not move or
|
||||||
|
// remove it during the current view session; reopening the channel recalculates it.
|
||||||
|
|
||||||
|
const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels'];
|
||||||
|
for (const group of feedGroups) {
|
||||||
|
const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : [];
|
||||||
|
const row = rows.find((item) => (
|
||||||
|
String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '')
|
||||||
|
&& Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber)
|
||||||
|
&& normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash)
|
||||||
|
));
|
||||||
|
if (row) {
|
||||||
|
row.readCount = persistedReadCount;
|
||||||
|
row.unreadCount = persistedUnreadCount;
|
||||||
|
row.readStateInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showStatus('');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2311,32 +2531,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
|
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
|
||||||
];
|
];
|
||||||
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
|
||||||
|
if (apiData?.isSubscribed) {
|
||||||
items.push({
|
items.push({
|
||||||
label: 'Отписаться от канала',
|
label: 'Отписаться от канала',
|
||||||
danger: true,
|
danger: true,
|
||||||
action: async () => {
|
action: () => unsubscribeFromChannel(apiData),
|
||||||
try {
|
});
|
||||||
const { login, storagePwd } = requireSigningSession();
|
} else {
|
||||||
await authService.addBlockFollowChannel({
|
items.push({
|
||||||
login,
|
label: 'Подписаться на канал',
|
||||||
storagePwd,
|
action: () => subscribeToChannel(apiData),
|
||||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
|
||||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
|
||||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
|
||||||
unfollow: true,
|
|
||||||
});
|
});
|
||||||
if (disposed) return;
|
|
||||||
const feed = await authService.listSubscriptionsFeed(login, 200);
|
|
||||||
if (disposed) return;
|
|
||||||
setChannelsFeed(feed, state.channelsIndex);
|
|
||||||
showToast('Вы отписались от канала');
|
|
||||||
void refresh();
|
|
||||||
} catch (error) {
|
|
||||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
@@ -2365,6 +2572,72 @@ export function render({ navigate, route, chrome }) {
|
|||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subscribeToChannel = async (apiData, event = null) => {
|
||||||
|
animatePress(event?.currentTarget);
|
||||||
|
try {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||||
|
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||||
|
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
await authService.addBlockFollowChannel({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||||
|
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||||
|
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||||
|
unfollow: false,
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.setChannelReadState({
|
||||||
|
login,
|
||||||
|
ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
|
||||||
|
channelName: apiData.channel?.name || apiData.channel?.channelName,
|
||||||
|
readCount: Math.max(0, Number(apiData.messagesCount || 0)),
|
||||||
|
timeMs: Date.now(),
|
||||||
|
storagePwd,
|
||||||
|
});
|
||||||
|
} catch (readStateError) {
|
||||||
|
showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await authService.listSubscriptionsFeed(login, 200);
|
||||||
|
if (disposed) return;
|
||||||
|
setChannelsFeed(feed, state.channelsIndex);
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Подписка на канал выполнена');
|
||||||
|
void refresh();
|
||||||
|
} catch (error) {
|
||||||
|
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribeFromChannel = async (apiData) => {
|
||||||
|
try {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
if (!apiData?.selector) throw new Error('Не удалось определить канал для отписки.');
|
||||||
|
await authService.addBlockFollowChannel({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||||
|
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||||
|
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||||
|
unfollow: true,
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
const feed = await authService.listSubscriptionsFeed(login, 200);
|
||||||
|
if (disposed) return;
|
||||||
|
setChannelsFeed(feed, state.channelsIndex);
|
||||||
|
showToast('Вы отписались от канала');
|
||||||
|
void refresh();
|
||||||
|
} catch (error) {
|
||||||
|
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onToggleLike = async (messageRef, action) => {
|
const onToggleLike = async (messageRef, action) => {
|
||||||
const actionKey = makeReactionActionKey(messageRef);
|
const actionKey = makeReactionActionKey(messageRef);
|
||||||
if (!actionKey) {
|
if (!actionKey) {
|
||||||
@@ -2688,6 +2961,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
}
|
}
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||||
|
showStatus,
|
||||||
isActive: () => !disposed,
|
isActive: () => !disposed,
|
||||||
onAddMessage: () => {
|
onAddMessage: () => {
|
||||||
openAddMessageModal({
|
openAddMessageModal({
|
||||||
@@ -2766,35 +3040,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
|
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubscribeChannel: async (event) => {
|
onSubscribeChannel: (event) => subscribeToChannel(apiData, event),
|
||||||
animatePress(event?.currentTarget);
|
|
||||||
try {
|
|
||||||
const { login, storagePwd } = requireSigningSession();
|
|
||||||
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
|
|
||||||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
|
||||||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
await authService.addBlockFollowChannel({
|
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
|
||||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
|
||||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
|
||||||
unfollow: false,
|
|
||||||
});
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
const feed = await authService.listSubscriptionsFeed(login, 200);
|
|
||||||
if (disposed) return;
|
|
||||||
setChannelsFeed(feed, state.channelsIndex);
|
|
||||||
softHaptic(15);
|
|
||||||
showToast('Подписка на канал выполнена');
|
|
||||||
void refresh();
|
|
||||||
} catch (error) {
|
|
||||||
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (disposed || seq !== refreshSeq) return;
|
if (disposed || seq !== refreshSeq) return;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const CHANNEL_TYPE_STORIES = 0;
|
|||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const DIARY_CHANNEL_NAME = 'diary';
|
const DIARY_CHANNEL_NAME = 'diary';
|
||||||
const DIARY_DISPLAY_NAME = 'Дневник';
|
const DIARY_DISPLAY_NAME = 'Дневник';
|
||||||
const CHANNEL_READ_SETTING_TYPE = 1;
|
|
||||||
|
|
||||||
const CHANNELS_VIEW_ALL = 'all';
|
const CHANNELS_VIEW_ALL = 'all';
|
||||||
const CHANNELS_VIEW_OWNED = 'owned';
|
const CHANNELS_VIEW_OWNED = 'owned';
|
||||||
@@ -111,58 +110,6 @@ function isVisibleChannelSummary(summary) {
|
|||||||
return !!ownerLogin && !!channelName;
|
return !!ownerLogin && !!channelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelReadSettingKey(summary) {
|
|
||||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
|
||||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
|
||||||
if (!ownerBch || !channelName) return '';
|
|
||||||
return `${ownerBch}/${channelName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureChannelReadBaselines(feed) {
|
|
||||||
const login = String(state.session.login || '').trim();
|
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
|
||||||
if (!login || !storagePwd) return;
|
|
||||||
|
|
||||||
let settingsPayload;
|
|
||||||
try {
|
|
||||||
settingsPayload = await authService.listUserSettings(login);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = new Set(
|
|
||||||
(Array.isArray(settingsPayload?.settings) ? settingsPayload.settings : [])
|
|
||||||
.filter((item) => Number(item?.setting_type) === CHANNEL_READ_SETTING_TYPE)
|
|
||||||
.map((item) => String(item?.setting_key || '').trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
|
|
||||||
const summaries = [
|
|
||||||
...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []),
|
|
||||||
...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []),
|
|
||||||
].filter(isVisibleChannelSummary);
|
|
||||||
|
|
||||||
for (const summary of summaries) {
|
|
||||||
const settingKey = channelReadSettingKey(summary);
|
|
||||||
if (!settingKey || existing.has(settingKey)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authService.upsertUserSetting({
|
|
||||||
login,
|
|
||||||
settingType: CHANNEL_READ_SETTING_TYPE,
|
|
||||||
settingKey,
|
|
||||||
timeMs: Date.now(),
|
|
||||||
valueText: '',
|
|
||||||
valueNum: Math.max(0, Number(summary?.messagesCount || 0)),
|
|
||||||
storagePwd,
|
|
||||||
});
|
|
||||||
existing.add(settingKey);
|
|
||||||
} catch {
|
|
||||||
// Не ломаем экран каналов из-за фоновой инициализации read-state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function avatarLetterFromName(name = '') {
|
function avatarLetterFromName(name = '') {
|
||||||
const first = Array.from(String(name || '').trim())[0] || '#';
|
const first = Array.from(String(name || '').trim())[0] || '#';
|
||||||
return first.toUpperCase();
|
return first.toUpperCase();
|
||||||
@@ -782,7 +729,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const title = displayTitle || channelName;
|
const title = displayTitle || channelName;
|
||||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
const technicalLabel = `@${ownerLogin}/${channelName}`;
|
||||||
const lastMessage = resolveChannelLastMessage(summary);
|
const lastMessage = resolveChannelLastMessage(summary);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1001,13 +948,21 @@ function renderChannelMain(channel) {
|
|||||||
const main = document.createElement('div');
|
const main = document.createElement('div');
|
||||||
main.className = 'channel-row-main';
|
main.className = 'channel-row-main';
|
||||||
|
|
||||||
|
const titleLine = document.createElement('div');
|
||||||
|
titleLine.className = 'channel-row-title-line';
|
||||||
|
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.className = 'channel-row-title';
|
title.className = 'channel-row-title';
|
||||||
title.textContent = channel.title;
|
title.textContent = channel.title;
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'channel-row-time';
|
||||||
|
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||||
|
titleLine.append(title, time);
|
||||||
|
|
||||||
const technical = document.createElement('p');
|
const technical = document.createElement('p');
|
||||||
technical.className = 'channel-row-technical';
|
technical.className = 'channel-row-technical';
|
||||||
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||||
|
|
||||||
if (channel.channelDescription) {
|
if (channel.channelDescription) {
|
||||||
const desc = document.createElement('p');
|
const desc = document.createElement('p');
|
||||||
@@ -1023,16 +978,13 @@ function renderChannelMain(channel) {
|
|||||||
preview.className = 'channel-row-message';
|
preview.className = 'channel-row-message';
|
||||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||||
|
|
||||||
const time = document.createElement('span');
|
previewLine.append(preview);
|
||||||
time.className = 'channel-row-time';
|
|
||||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
|
||||||
previewLine.append(preview, time);
|
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'channel-row-owner channel-counter-meta';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.prepend(title, technical);
|
main.prepend(titleLine, technical);
|
||||||
main.append(previewLine, meta);
|
main.append(previewLine, meta);
|
||||||
return main;
|
return main;
|
||||||
}
|
}
|
||||||
@@ -1094,16 +1046,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.append(list);
|
container.append(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBottomCta({ button }) {
|
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||||
if (!button) return;
|
if (!silent) renderSkeletonList(contentEl, 5);
|
||||||
button.hidden = true;
|
|
||||||
button.textContent = '';
|
|
||||||
button.className = 'channels-bottom-action';
|
|
||||||
button.onclick = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
|
||||||
renderSkeletonList(contentEl, 5);
|
|
||||||
|
|
||||||
if (!state.session.isAuthorized) {
|
if (!state.session.isAuthorized) {
|
||||||
setChannelsFeed(null, {});
|
setChannelsFeed(null, {});
|
||||||
@@ -1128,13 +1072,18 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||||
void ensureChannelReadBaselines(feed);
|
|
||||||
let diaryPayload = null;
|
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||||
try {
|
// The server/API implementation is preserved so the feature can be restored later.
|
||||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
// See: docs/закомментировано/Личный_дневник.md
|
||||||
} catch {
|
// let diaryPayload = null;
|
||||||
diaryPayload = null;
|
// try {
|
||||||
}
|
// diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||||
|
// } catch {
|
||||||
|
// diaryPayload = null;
|
||||||
|
// }
|
||||||
|
const diaryPayload = null;
|
||||||
|
|
||||||
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
|
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
|
||||||
|
|
||||||
listState.channels = toListModel(groups);
|
listState.channels = toListModel(groups);
|
||||||
@@ -1148,6 +1097,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
|||||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (silent) return;
|
||||||
setChannelsFeed(null, {});
|
setChannelsFeed(null, {});
|
||||||
contentEl.innerHTML = '';
|
contentEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -1215,11 +1165,22 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
|
||||||
bottomCta.type = 'button';
|
|
||||||
|
|
||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
|
let countersRefreshTimer = null;
|
||||||
|
let lastChannelsUnreadCount = Math.max(0, Number(state.userCounters?.channelsUnreadCount || 0) || 0);
|
||||||
|
const unsubscribeCountersChanged = authService.onEvent('UserCountersChanged', (event) => {
|
||||||
|
const nextChannelsUnreadCount = Math.max(0, Number(event?.payload?.channelsUnreadCount || 0) || 0);
|
||||||
|
if (nextChannelsUnreadCount === lastChannelsUnreadCount) return;
|
||||||
|
lastChannelsUnreadCount = nextChannelsUnreadCount;
|
||||||
|
|
||||||
|
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||||
|
countersRefreshTimer = window.setTimeout(() => {
|
||||||
|
countersRefreshTimer = null;
|
||||||
|
void loadFeedAndRender({ screen, listState, contentEl, navigate, silent: true });
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||||
|
|
||||||
@@ -1234,24 +1195,23 @@ export function render({ navigate, route, chrome }) {
|
|||||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
topMenuBtn.style.display = '';
|
topMenuBtn.style.display = '';
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome?.setTopbar(topBarEl);
|
chrome?.setTopbar(topBarEl);
|
||||||
screen.append(contentEl, bottomCta);
|
screen.append(contentEl);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
|
||||||
|
|
||||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
rerenderList();
|
rerenderList();
|
||||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||||
|
unsubscribeCountersChanged();
|
||||||
channelsFilterMenu.destroy();
|
channelsFilterMenu.destroy();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -902,7 +902,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
||||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
|
||||||
let historyHasMore = true;
|
let historyHasMore = true;
|
||||||
let historyLoading = false;
|
let historyLoading = false;
|
||||||
let historyNextBeforeTimeMs = 0;
|
let historyNextBeforeTimeMs = 0;
|
||||||
@@ -910,16 +909,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
let historyBootstrapped = false;
|
let historyBootstrapped = false;
|
||||||
let boundScrollContainer = null;
|
let boundScrollContainer = null;
|
||||||
let unreadSeparatorVisible = hasUnreadIncoming;
|
let unreadSeparatorVisible = hasUnreadIncoming;
|
||||||
let unreadSeparatorHideTimer = null;
|
|
||||||
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
||||||
|
|
||||||
const clearUnreadSeparatorHideTimer = () => {
|
|
||||||
if (unreadSeparatorHideTimer) {
|
|
||||||
window.clearTimeout(unreadSeparatorHideTimer);
|
|
||||||
unreadSeparatorHideTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId, {
|
||||||
onOpenActions: handleOpenActions,
|
onOpenActions: handleOpenActions,
|
||||||
@@ -928,13 +919,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
showUnreadSeparator: unreadSeparatorVisible,
|
showUnreadSeparator: unreadSeparatorVisible,
|
||||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||||
});
|
});
|
||||||
if (unreadSeparatorVisible) {
|
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
if (!unreadSeparatorVisible) return;
|
if (!unreadSeparatorVisible) return;
|
||||||
unreadSeparatorVisible = false;
|
unreadSeparatorVisible = false;
|
||||||
if (rerender) {
|
if (rerender) {
|
||||||
@@ -942,14 +929,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleUnreadSeparatorAutoHide = () => {
|
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
if (!unreadSeparatorVisible) return;
|
|
||||||
unreadSeparatorHideTimer = window.setTimeout(() => {
|
|
||||||
hideUnreadSeparator({ rerender: true });
|
|
||||||
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReadAloud = async (msg) => {
|
const handleReadAloud = async (msg) => {
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
showTtsMissingConfigDialog();
|
showTtsMissingConfigDialog();
|
||||||
@@ -1371,12 +1350,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||||
const text = safeText.trim();
|
const text = safeText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
hideUnreadSeparator({ rerender: false });
|
|
||||||
const editing = activeEdit;
|
const editing = activeEdit;
|
||||||
const replying = !editing ? activeReply : null;
|
const replying = !editing ? activeReply : null;
|
||||||
const finalText = editing
|
const finalText = editing
|
||||||
? `${String(editing?.prefixText || '')}${text}`
|
? `${String(editing?.prefixText || '')}${text}`
|
||||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||||
|
|
||||||
|
// A new outgoing message immediately ends the visual "Новые сообщения" section.
|
||||||
|
// Do this before awaiting the server: the divider is a UI-session marker, not a
|
||||||
|
// delivery/read receipt indicator. Editing an existing message does not clear it.
|
||||||
|
if (!editing) {
|
||||||
|
hideUnreadSeparator({ rerender: false });
|
||||||
|
}
|
||||||
|
|
||||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||||
@@ -1629,13 +1615,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const handleIncomingChatRefresh = async (event) => {
|
const handleIncomingChatRefresh = async (event) => {
|
||||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
if (Number(event?.detail?.messageType || 0) === 1) {
|
// Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт
|
||||||
if (!unreadSeparatorVisible) {
|
// новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к
|
||||||
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
// непрочитанным, которые существовали ДО открытия экрана чата.
|
||||||
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
|
||||||
}
|
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
|
||||||
preserveComposerSelection(input, () => {
|
preserveComposerSelection(input, () => {
|
||||||
renderChatLog({ scrollMode: 'latest' });
|
renderChatLog({ scrollMode: 'latest' });
|
||||||
});
|
});
|
||||||
@@ -1660,10 +1642,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
markAsRead: false,
|
markAsRead: false,
|
||||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||||
showUnreadSeparator: unreadSeparatorVisible,
|
showUnreadSeparator: unreadSeparatorVisible,
|
||||||
|
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||||
});
|
});
|
||||||
if (unreadSeparatorVisible) {
|
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (markChatRead(chatId) > 0) {
|
if (markChatRead(chatId) > 0) {
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
@@ -1681,7 +1661,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
};
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function render({ navigate }) {
|
|||||||
enterButton.disabled = true;
|
enterButton.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
|
|
||||||
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
|
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
|
||||||
const resolved = await authService.resolveLoginForAuth(currentLogin);
|
const resolved = await authService.resolveLoginForAuth(currentLogin);
|
||||||
|
|||||||
@@ -290,11 +290,11 @@ function renderRow(item) {
|
|||||||
<p class="dm-row-last-message"></p>
|
<p class="dm-row-last-message"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-row-meta-col">
|
<div class="dm-row-meta-col">
|
||||||
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
|
||||||
<div class="dm-row-meta-line">
|
<div class="dm-row-meta-line">
|
||||||
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const titleEl = row.querySelector('.dm-row-title');
|
const titleEl = row.querySelector('.dm-row-title');
|
||||||
|
|||||||
@@ -201,6 +201,269 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
|
|
||||||
let persistedCenterLogin = '';
|
let persistedCenterLogin = '';
|
||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
|
let persistedGraphHistory = [];
|
||||||
|
|
||||||
|
const HISTORY_MAX_CENTERS = 4;
|
||||||
|
const HISTORY_CENTER_GAP_MIN = 280;
|
||||||
|
const HISTORY_CENTER_GAP_STEP = 72;
|
||||||
|
const HISTORY_CENTER_CLEARANCE = 110;
|
||||||
|
const HISTORY_ORBIT_FIRST_R = 104;
|
||||||
|
const HISTORY_ORBIT_GAP = 88;
|
||||||
|
const HISTORY_ORBIT_NODE_GAP = 84;
|
||||||
|
const HISTORY_NODE_CLEARANCE = 92;
|
||||||
|
|
||||||
|
function historyHash01(value) {
|
||||||
|
let h = 2166136261;
|
||||||
|
const text = String(value || '');
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
h ^= text.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return ((h >>> 0) % 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyOrbitCapacity(radius) {
|
||||||
|
const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1);
|
||||||
|
const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r)));
|
||||||
|
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyOrbitPlacements(total, seed = '') {
|
||||||
|
const count = Math.max(0, Number(total) || 0);
|
||||||
|
const out = new Array(count);
|
||||||
|
let start = 0;
|
||||||
|
let ring = 0;
|
||||||
|
const seedPhase = historyHash01(seed) * Math.PI * 2;
|
||||||
|
while (start < count) {
|
||||||
|
const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP;
|
||||||
|
const capacity = historyOrbitCapacity(radius);
|
||||||
|
const ringCount = Math.min(capacity, count - start);
|
||||||
|
const phase = seedPhase + ring * 0.43;
|
||||||
|
for (let i = 0; i < ringCount; i += 1) {
|
||||||
|
out[start + i] = {
|
||||||
|
radius,
|
||||||
|
angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
start += ringCount;
|
||||||
|
ring += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyClusterRadius(snapshot) {
|
||||||
|
const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : [];
|
||||||
|
const centerKey = normKey(snapshot?.centerLogin);
|
||||||
|
const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length;
|
||||||
|
const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || '');
|
||||||
|
const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0);
|
||||||
|
return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryEngineModel(history) {
|
||||||
|
const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS);
|
||||||
|
const latest = snapshots[snapshots.length - 1];
|
||||||
|
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
||||||
|
|
||||||
|
const centerPos = new Map();
|
||||||
|
const centerOrder = new Map();
|
||||||
|
const clusterRadius = new Map();
|
||||||
|
centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 });
|
||||||
|
snapshots.forEach((snap, index) => {
|
||||||
|
const key = normKey(snap.centerLogin);
|
||||||
|
centerOrder.set(key, index);
|
||||||
|
clusterRadius.set(key, historyClusterRadius(snap));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода
|
||||||
|
// настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались.
|
||||||
|
for (let i = snapshots.length - 1; i > 0; i -= 1) {
|
||||||
|
const current = snapshots[i];
|
||||||
|
const previous = snapshots[i - 1];
|
||||||
|
const currentKey = normKey(current.centerLogin);
|
||||||
|
const previousKey = normKey(previous.centerLogin);
|
||||||
|
const curPos = centerPos.get(currentKey) || { x: 0, y: 0 };
|
||||||
|
const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0;
|
||||||
|
const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE;
|
||||||
|
const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE;
|
||||||
|
let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR);
|
||||||
|
let candidate = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
|
candidate = {
|
||||||
|
x: curPos.x - Math.cos(angle) * gap,
|
||||||
|
y: curPos.y - Math.sin(angle) * gap,
|
||||||
|
};
|
||||||
|
let collides = false;
|
||||||
|
for (const [placedKey, placed] of centerPos.entries()) {
|
||||||
|
const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE;
|
||||||
|
const minDist = prevR + placedR;
|
||||||
|
if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) {
|
||||||
|
collides = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!collides) break;
|
||||||
|
gap += HISTORY_CENTER_GAP_STEP;
|
||||||
|
}
|
||||||
|
centerPos.set(previousKey, candidate || {
|
||||||
|
x: curPos.x - Math.cos(angle) * gap,
|
||||||
|
y: curPos.y - Math.sin(angle) * gap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map();
|
||||||
|
const latestPlacementParent = new Map();
|
||||||
|
const edgeMap = new Map();
|
||||||
|
|
||||||
|
// Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям.
|
||||||
|
snapshots.forEach((snap, snapIndex) => {
|
||||||
|
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||||
|
const centerKey = normKey(snap.centerLogin);
|
||||||
|
const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0];
|
||||||
|
if (center) {
|
||||||
|
nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
||||||
|
relations.forEach((node) => {
|
||||||
|
const key = normKey(node?.id);
|
||||||
|
if (!key) return;
|
||||||
|
nodeMap.set(key, { ...nodeMap.get(key), ...node });
|
||||||
|
|
||||||
|
const a = centerKey;
|
||||||
|
const b = key;
|
||||||
|
const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
|
edgeMap.set(edgeKey, {
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
relationType: node.relationType || 'contact',
|
||||||
|
strength: Number(node.strength) || 0.5,
|
||||||
|
snapIndex,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Все исторические центры сразу считаются занятыми местами.
|
||||||
|
const occupied = [];
|
||||||
|
for (const [centerKey, pos] of centerPos.entries()) {
|
||||||
|
occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => {
|
||||||
|
if (row.key === key) return false;
|
||||||
|
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь
|
||||||
|
// встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге.
|
||||||
|
for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) {
|
||||||
|
const snap = snapshots[snapIndex];
|
||||||
|
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||||
|
const centerKey = normKey(snap.centerLogin);
|
||||||
|
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
||||||
|
const placements = historyOrbitPlacements(relations.length, snap.centerLogin);
|
||||||
|
const base = centerPos.get(centerKey) || { x: 0, y: 0 };
|
||||||
|
|
||||||
|
relations.forEach((node, index) => {
|
||||||
|
const key = normKey(node?.id);
|
||||||
|
if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return;
|
||||||
|
const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 };
|
||||||
|
let chosen = null;
|
||||||
|
// Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус.
|
||||||
|
for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) {
|
||||||
|
const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55;
|
||||||
|
const angularSamples = 18 + radialStep * 2;
|
||||||
|
for (let step = 0; step < angularSamples; step += 1) {
|
||||||
|
const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1);
|
||||||
|
const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples);
|
||||||
|
const x = base.x + Math.cos(angle) * radius;
|
||||||
|
const y = base.y + Math.sin(angle) * radius;
|
||||||
|
if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) {
|
||||||
|
chosen = { x, y };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chosen) {
|
||||||
|
const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55;
|
||||||
|
chosen = {
|
||||||
|
x: base.x + Math.cos(preferred.angle) * radius,
|
||||||
|
y: base.y + Math.sin(preferred.angle) * radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex });
|
||||||
|
occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [centerKey, pos] of centerPos.entries()) {
|
||||||
|
const node = nodeMap.get(centerKey);
|
||||||
|
if (!node) continue;
|
||||||
|
nodeMap.set(centerKey, {
|
||||||
|
...node,
|
||||||
|
layoutX: pos.x,
|
||||||
|
layoutY: pos.y,
|
||||||
|
fixedLayout: true,
|
||||||
|
keepVisible: true,
|
||||||
|
isHistoryCenter: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, placement] of latestPlacementParent.entries()) {
|
||||||
|
const node = nodeMap.get(key);
|
||||||
|
if (!node || centerOrder.has(key)) continue;
|
||||||
|
nodeMap.set(key, {
|
||||||
|
...node,
|
||||||
|
layoutX: placement.x,
|
||||||
|
layoutY: placement.y,
|
||||||
|
fixedLayout: true,
|
||||||
|
parentId: placement.centerKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeParentsByChild = new Map();
|
||||||
|
const addEdgeParent = (childKey, parentKey, edge) => {
|
||||||
|
if (!childKey || !parentKey || childKey === parentKey) return;
|
||||||
|
const list = edgeParentsByChild.get(childKey) || [];
|
||||||
|
if (!list.some((row) => row.id === parentKey)) {
|
||||||
|
list.push({
|
||||||
|
id: parentKey,
|
||||||
|
relationType: edge.relationType,
|
||||||
|
strength: edge.strength,
|
||||||
|
shining: edge.shining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
edgeParentsByChild.set(childKey, list);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const edge of edgeMap.values()) {
|
||||||
|
const aCenterIndex = centerOrder.get(edge.a);
|
||||||
|
const bCenterIndex = centerOrder.get(edge.b);
|
||||||
|
if (aCenterIndex !== undefined && bCenterIndex !== undefined) {
|
||||||
|
if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge);
|
||||||
|
else addEdgeParent(edge.b, edge.a, edge);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge);
|
||||||
|
else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = [...nodeMap.entries()].map(([key, node]) => ({
|
||||||
|
...node,
|
||||||
|
id: key,
|
||||||
|
login: node.login || node.id || key,
|
||||||
|
tier: 1,
|
||||||
|
fixedLayout: true,
|
||||||
|
edgeParents: edgeParentsByChild.get(key) || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusId: normKey(latest.engineModel.focusId),
|
||||||
|
nodes,
|
||||||
|
preserveHistory: snapshots.length > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate, route, chrome } = {}) {
|
export function render({ navigate, route, chrome } = {}) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
@@ -208,6 +471,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
persistedCenterLogin = '';
|
persistedCenterLogin = '';
|
||||||
persistedCenterHistory = [];
|
persistedCenterHistory = [];
|
||||||
|
persistedGraphHistory = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@@ -221,27 +485,36 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
|
|
||||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||||
|
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
|
||||||
let engine = null;
|
let engine = null;
|
||||||
let loadSeq = 0;
|
let loadSeq = 0;
|
||||||
|
|
||||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
// Независимые фильтры карты. Оба выключены = показываем всё.
|
||||||
|
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
|
||||||
const FILTERS = {
|
const FILTERS = {
|
||||||
all: { label: 'Все', pred: () => true },
|
close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
|
||||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
|
|
||||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
||||||
};
|
};
|
||||||
const FILTER_ORDER = ['all', 'friends', 'shining'];
|
const FILTER_ORDER = ['close', 'shining'];
|
||||||
let activeFilter = 'all';
|
const activeFilters = new Set();
|
||||||
const filterChips = {};
|
const filterChips = {};
|
||||||
|
|
||||||
|
function currentFilterPredicate(node) {
|
||||||
|
for (const key of activeFilters) {
|
||||||
|
if (!FILTERS[key].pred(node)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilter(key) {
|
function applyFilter(key) {
|
||||||
if (!FILTERS[key]) return;
|
if (!FILTERS[key]) return;
|
||||||
activeFilter = key;
|
if (activeFilters.has(key)) activeFilters.delete(key);
|
||||||
|
else activeFilters.add(key);
|
||||||
FILTER_ORDER.forEach((k) => {
|
FILTER_ORDER.forEach((k) => {
|
||||||
const el = filterChips[k];
|
const el = filterChips[k];
|
||||||
if (el) el.classList.toggle('is-active', k === activeFilter);
|
if (el) el.classList.toggle('is-active', activeFilters.has(k));
|
||||||
});
|
});
|
||||||
if (engine) engine.setFilter(FILTERS[key].pred);
|
if (engine) engine.setFilter(currentFilterPredicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileInfoRoute(login) {
|
function profileInfoRoute(login) {
|
||||||
@@ -254,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
function persistHistory() {
|
function persistHistory() {
|
||||||
persistedCenterLogin = centerLogin;
|
persistedCenterLogin = centerLogin;
|
||||||
persistedCenterHistory = [...centerHistory];
|
persistedCenterHistory = [...centerHistory];
|
||||||
|
persistedGraphHistory = [...graphHistory];
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncLinksUrl(login, { push = false } = {}) {
|
function syncLinksUrl(login, { push = false } = {}) {
|
||||||
@@ -392,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
stage: board,
|
stage: board,
|
||||||
model,
|
model,
|
||||||
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
||||||
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
|
onNodeTap: (node) => {
|
||||||
|
const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0);
|
||||||
|
void load(node.login, { pushHistory: true, transitionAngle });
|
||||||
|
},
|
||||||
// тап по центру — полноценный профиль
|
// тап по центру — полноценный профиль
|
||||||
onCenterTap: (node) => {
|
onCenterTap: (node) => {
|
||||||
const routeTo = profileInfoRoute(node.login);
|
const routeTo = profileInfoRoute(node.login);
|
||||||
@@ -415,7 +692,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(nextCenterLogin = '', { pushHistory = false } = {}) {
|
async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) {
|
||||||
const requestId = ++loadSeq;
|
const requestId = ++loadSeq;
|
||||||
const prevCenter = centerLogin;
|
const prevCenter = centerLogin;
|
||||||
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
||||||
@@ -430,10 +707,35 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||||
|
|
||||||
const graphModel = buildGraphModel(graph, targetCenter);
|
const graphModel = buildGraphModel(graph, targetCenter);
|
||||||
const engineModel = engineModelFromGraphModel(graphModel);
|
const snapshotModel = engineModelFromGraphModel(graphModel);
|
||||||
|
const snapshot = {
|
||||||
|
centerLogin: targetCenter,
|
||||||
|
engineModel: snapshotModel,
|
||||||
|
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||||
|
// Один и тот же пользователь не хранится как два исторических центра: повторный переход
|
||||||
|
// переносит его в конец истории, где он становится актуальным центром.
|
||||||
|
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
|
||||||
|
graphHistory.push(snapshot);
|
||||||
|
if (graphHistory.length > HISTORY_MAX_CENTERS) {
|
||||||
|
graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS);
|
||||||
|
centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const last = graphHistory[graphHistory.length - 1];
|
||||||
|
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
|
||||||
|
snapshot.transitionAngle = Number(last.transitionAngle) || 0;
|
||||||
|
graphHistory[graphHistory.length - 1] = snapshot;
|
||||||
|
} else {
|
||||||
|
graphHistory = [snapshot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const engineModel = buildHistoryEngineModel(graphHistory);
|
||||||
ensureEngine(engineModel);
|
ensureEngine(engineModel);
|
||||||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
||||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
|
||||||
|
|
||||||
persistHistory();
|
persistHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -475,6 +777,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
if (routeLogin) {
|
if (routeLogin) {
|
||||||
centerLogin = routeLogin;
|
centerLogin = routeLogin;
|
||||||
centerHistory = [];
|
centerHistory = [];
|
||||||
|
graphHistory = [];
|
||||||
persistHistory();
|
persistHistory();
|
||||||
void load(centerLogin, { pushHistory: false });
|
void load(centerLogin, { pushHistory: false });
|
||||||
} else if (keepHistory && centerLogin) {
|
} else if (keepHistory && centerLogin) {
|
||||||
@@ -482,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
} else {
|
} else {
|
||||||
centerLogin = normalizeLogin(state.session.login || '');
|
centerLogin = normalizeLogin(state.session.login || '');
|
||||||
centerHistory = [];
|
centerHistory = [];
|
||||||
|
graphHistory = [];
|
||||||
persistHistory();
|
persistHistory();
|
||||||
if (centerLogin) {
|
if (centerLogin) {
|
||||||
void load(centerLogin, { pushHistory: false });
|
void load(centerLogin, { pushHistory: false });
|
||||||
@@ -499,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
FILTER_ORDER.forEach((key) => {
|
FILTER_ORDER.forEach((key) => {
|
||||||
const chip = document.createElement('button');
|
const chip = document.createElement('button');
|
||||||
chip.type = 'button';
|
chip.type = 'button';
|
||||||
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
|
chip.className = `fg-filter-chip${activeFilters.has(key) ? ' is-active' : ''}`;
|
||||||
chip.textContent = FILTERS[key].label;
|
chip.textContent = FILTERS[key].label;
|
||||||
chip.addEventListener('click', () => applyFilter(key));
|
chip.addEventListener('click', () => applyFilter(key));
|
||||||
filterChips[key] = chip;
|
filterChips[key] = chip;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) {
|
|||||||
relationType: 'self',
|
relationType: 'self',
|
||||||
strength: 1,
|
strength: 1,
|
||||||
shining: Boolean(centerMark?.shine),
|
shining: Boolean(centerMark?.shine),
|
||||||
|
official: Boolean(centerMark?.official),
|
||||||
tier: 1,
|
tier: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) {
|
|||||||
relationType: relationTypeFromRelation(r),
|
relationType: relationTypeFromRelation(r),
|
||||||
strength: deriveStrength(r || {}),
|
strength: deriveStrength(r || {}),
|
||||||
shining: Boolean(r?.mark?.shine),
|
shining: Boolean(r?.mark?.shine),
|
||||||
|
official: Boolean(r?.mark?.official),
|
||||||
tier: 1,
|
tier: 1,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами
|
|||||||
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
||||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||||
|
const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты
|
||||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||||||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||||||
@@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const rebuildIndex = () => {
|
const rebuildIndex = () => {
|
||||||
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||||
hasDeep = nodes.some((n) => n.tier >= 2);
|
hasDeep = nodes.some((n) => n.tier >= 2);
|
||||||
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
|
|
||||||
childCountByParent = new Map();
|
childCountByParent = new Map();
|
||||||
degreeById = new Map();
|
degreeById = new Map();
|
||||||
|
|
||||||
|
const hasExplicitEdges = nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0);
|
||||||
|
if (hasExplicitEdges) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const refs = Array.isArray(n.edgeParents) ? n.edgeParents : [];
|
||||||
|
for (const ref of refs) {
|
||||||
|
const parentId = String(ref?.id || '');
|
||||||
|
if (!parentId || !nodeById.has(parentId)) continue;
|
||||||
|
degreeById.set(String(n.id), (degreeById.get(String(n.id)) || 0) + 1);
|
||||||
|
degreeById.set(parentId, (degreeById.get(parentId) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let tier1count = 0;
|
let tier1count = 0;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.tier >= 2 && n.parentId) {
|
if (n.tier >= 2 && n.parentId) {
|
||||||
const i = childCountByParent.get(n.parentId) || 0;
|
const i = childCountByParent.get(n.parentId) || 0;
|
||||||
n.sibIndex = i;
|
n.sibIndex = i;
|
||||||
childCountByParent.set(n.parentId, i + 1);
|
childCountByParent.set(n.parentId, i + 1);
|
||||||
degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь
|
degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1);
|
||||||
} else if (n.tier === 1 && String(n.id) !== focusId) {
|
} else if (n.tier === 1 && String(n.id) !== focusId) {
|
||||||
tier1count += 1;
|
tier1count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня
|
degreeById.set(focusId, tier1count);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
|
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
|
||||||
@@ -410,7 +425,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
||||||
const tier1Orbit = buildOrbitPlacements(tier1.length);
|
const tier1Orbit = buildOrbitPlacements(tier1.length);
|
||||||
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
|
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
|
||||||
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] }));
|
tier1.forEach((p, i) => specs.push({
|
||||||
|
src: p,
|
||||||
|
id: String(p.id),
|
||||||
|
isFocus: false,
|
||||||
|
index: i,
|
||||||
|
total: tier1.length,
|
||||||
|
dotOnly: i >= MAX_FULL_NODES && !p.keepVisible,
|
||||||
|
orbit: p.fixedLayout ? null : tier1Orbit[i],
|
||||||
|
}));
|
||||||
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
|
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
|
||||||
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
|
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
|
||||||
return { focusId: fId, specs };
|
return { focusId: fId, specs };
|
||||||
@@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
|
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
|
||||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||||
const tier = Number(src.tier) || 1;
|
const tier = Number(src.tier) || 1;
|
||||||
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
|
// Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view
|
||||||
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
|
// может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему
|
||||||
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
|
// кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер.
|
||||||
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
|
const layoutX = Number(src.layoutX);
|
||||||
|
const layoutY = Number(src.layoutY);
|
||||||
|
const fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY);
|
||||||
|
const targetR = fixedLayout ? Math.hypot(layoutX, layoutY) : (isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R));
|
||||||
|
const angle = fixedLayout ? Math.atan2(layoutY, layoutX) : (isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)));
|
||||||
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
||||||
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||||
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||||
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
|
const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR);
|
||||||
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
|
const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR);
|
||||||
const tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
|
||||||
const ty = isFocus ? 0 : Math.sin(angle) * targetR;
|
|
||||||
const el = buildNodeElement(src, isFocus, tier, dotOnly);
|
const el = buildNodeElement(src, isFocus, tier, dotOnly);
|
||||||
world.append(el);
|
world.append(el);
|
||||||
return {
|
return {
|
||||||
...src,
|
...src,
|
||||||
isFocus,
|
isFocus,
|
||||||
tier,
|
tier,
|
||||||
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус)
|
parentId: String(src.parentId || ''), // layout/deep parent; для history-рёбер используется edgeParents
|
||||||
|
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
|
||||||
|
fixedLayout,
|
||||||
|
keepVisible: Boolean(src.keepVisible),
|
||||||
|
official: Boolean(src.official),
|
||||||
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
||||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||||
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
|
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
|
||||||
@@ -501,6 +530,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
||||||
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
||||||
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
|
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
|
||||||
|
const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg';
|
||||||
function buildPngOrb(src, opts) {
|
function buildPngOrb(src, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -528,6 +558,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function syncOfficialBadge(el, official) {
|
||||||
|
if (!(el instanceof HTMLElement)) return;
|
||||||
|
const dot = el.querySelector('.node-dot');
|
||||||
|
if (!(dot instanceof HTMLElement)) return;
|
||||||
|
const existing = dot.querySelector('.fg-official-badge');
|
||||||
|
if (!official) {
|
||||||
|
existing?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing) return;
|
||||||
|
const badge = document.createElement('img');
|
||||||
|
badge.className = 'fg-official-badge';
|
||||||
|
badge.src = OFFICIAL_BADGE_SRC;
|
||||||
|
badge.alt = '';
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
dot.append(badge);
|
||||||
|
}
|
||||||
|
|
||||||
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
@@ -537,6 +586,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
'ui-button', 'fg-node', 'fg-dot',
|
'ui-button', 'fg-node', 'fg-dot',
|
||||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
||||||
src.shining ? 'is-shine' : '',
|
src.shining ? 'is-shine' : '',
|
||||||
|
src.official ? 'is-official' : '',
|
||||||
`is-${src.relationType || 'contact'}`,
|
`is-${src.relationType || 'contact'}`,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
el.dataset.nodeId = String(src.id);
|
el.dataset.nodeId = String(src.id);
|
||||||
@@ -549,6 +599,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
'ui-button', 'fg-node',
|
'ui-button', 'fg-node',
|
||||||
isFocus ? 'is-focus' : '',
|
isFocus ? 'is-focus' : '',
|
||||||
src.shining ? 'is-shine' : '',
|
src.shining ? 'is-shine' : '',
|
||||||
|
src.official ? 'is-official' : '',
|
||||||
`is-${src.relationType || 'contact'}`,
|
`is-${src.relationType || 'contact'}`,
|
||||||
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
|
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
|
||||||
tier >= 2 ? 'is-secondary' : '',
|
tier >= 2 ? 'is-secondary' : '',
|
||||||
@@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
|
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
|
||||||
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
|
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
|
||||||
el.append(dot);
|
el.append(dot);
|
||||||
|
syncOfficialBadge(el, Boolean(src.official));
|
||||||
|
|
||||||
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'fg-node-badge';
|
|
||||||
badge.hidden = true;
|
|
||||||
el.append(badge);
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'fg-node-label';
|
label.className = 'fg-node-label';
|
||||||
@@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex.
|
// Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой.
|
||||||
function updateBadges() {
|
function updateBadges() {}
|
||||||
for (const n of nodes) {
|
|
||||||
const badge = n.el.querySelector('.fg-node-badge');
|
|
||||||
if (!badge) continue; // у точек (dotOnly) бейджа нет
|
|
||||||
const deg = degreeById.get(String(n.id)) || 0;
|
|
||||||
if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; }
|
|
||||||
else { badge.hidden = true; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
|
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
|
||||||
function updateA11y() {
|
function updateA11y() {
|
||||||
@@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.strength = strength;
|
node.strength = strength;
|
||||||
node.relationType = src.relationType;
|
node.relationType = src.relationType;
|
||||||
node.shining = Boolean(src.shining);
|
node.shining = Boolean(src.shining);
|
||||||
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
|
node.official = Boolean(src.official);
|
||||||
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
|
node.keepVisible = Boolean(src.keepVisible);
|
||||||
|
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
||||||
|
const layoutX = Number(src.layoutX);
|
||||||
|
const layoutY = Number(src.layoutY);
|
||||||
|
node.fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY);
|
||||||
|
node.targetR = node.fixedLayout ? Math.hypot(layoutX, layoutY) : (spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R));
|
||||||
|
node.angle = node.fixedLayout ? Math.atan2(layoutY, layoutX) : (spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)));
|
||||||
node.orbitRing = spec.orbit?.ring || 0;
|
node.orbitRing = spec.orbit?.ring || 0;
|
||||||
node.tx = Math.cos(node.angle) * node.targetR;
|
node.tx = node.fixedLayout ? layoutX : Math.cos(node.angle) * node.targetR;
|
||||||
node.ty = Math.sin(node.angle) * node.targetR;
|
node.ty = node.fixedLayout ? layoutY : Math.sin(node.angle) * node.targetR;
|
||||||
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||||
node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||||
node.hidden = false;
|
node.hidden = false;
|
||||||
@@ -628,8 +673,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
|
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
|
||||||
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||||
node.el.className = spec.dotOnly
|
node.el.className = spec.dotOnly
|
||||||
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||||
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
|
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
|
||||||
|
if (!spec.dotOnly) syncOfficialBadge(node.el, node.official);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Рендер ----------------------------------------------------------------
|
// --- Рендер ----------------------------------------------------------------
|
||||||
@@ -637,6 +683,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
|
world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает
|
||||||
|
// обе центральные оси viewport. На крайнем положении к центральной линии прижимается край
|
||||||
|
// крайнего аватара — полностью выбросить весь граф за экран больше нельзя.
|
||||||
|
function clampCameraPosition(nextX, nextY) {
|
||||||
|
const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02);
|
||||||
|
if (!visible.length) return { x: 0, y: 0 };
|
||||||
|
let minX = Infinity; let maxX = -Infinity;
|
||||||
|
let minY = Infinity; let maxY = -Infinity;
|
||||||
|
for (const n of visible) {
|
||||||
|
const baseR = n.dotOnly ? (Number(n.dotRadius) || 5) : ORB_R;
|
||||||
|
const r = baseR * (Number(n.scale) || 1) * (Number(n.depthScale) || 1);
|
||||||
|
minX = Math.min(minX, n.x - r);
|
||||||
|
maxX = Math.max(maxX, n.x + r);
|
||||||
|
minY = Math.min(minY, n.y - r);
|
||||||
|
maxY = Math.max(maxY, n.y + r);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return { x: nextX, y: nextY };
|
||||||
|
const minCamX = -maxX * zoom;
|
||||||
|
const maxCamX = -minX * zoom;
|
||||||
|
const minCamY = -maxY * zoom;
|
||||||
|
const maxCamY = -minY * zoom;
|
||||||
|
return {
|
||||||
|
x: Math.max(minCamX, Math.min(maxCamX, nextX)),
|
||||||
|
y: Math.max(minCamY, Math.min(maxCamY, nextY)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
|
// Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
|
||||||
// в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
|
// в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
|
||||||
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
|
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
|
||||||
@@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
zoom = z1;
|
zoom = z1;
|
||||||
camX = sx - centerX - wx * z1;
|
camX = sx - centerX - wx * z1;
|
||||||
camY = sy - centerY - wy * z1;
|
camY = sy - centerY - wy * z1;
|
||||||
|
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||||
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
|
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
renderEdges();
|
renderEdges();
|
||||||
@@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
n.lod = full ? 'full' : 'dot';
|
n.lod = full ? 'full' : 'dot';
|
||||||
n.dotOnly = !full;
|
n.dotOnly = !full;
|
||||||
n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей
|
n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей
|
||||||
if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } }
|
}
|
||||||
|
|
||||||
|
function renderHistoryEdges() {
|
||||||
|
const Z = zoom;
|
||||||
|
const parts = [];
|
||||||
|
const tx = (n) => centerX + camX + n.x * Z;
|
||||||
|
const ty = (n) => centerY + camY + n.y * Z;
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
const refs = Array.isArray(n.edgeParents) ? n.edgeParents : [];
|
||||||
|
if (!refs.length) continue;
|
||||||
|
const childOpacity = (typeof n.opacity === 'number' ? n.opacity : 1) * (n.spotCur ?? 1);
|
||||||
|
if (n.hidden && childOpacity <= 0.02) continue;
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const parent = nodeById.get(String(ref?.id || ''));
|
||||||
|
if (!parent || parent === n) continue;
|
||||||
|
const parentOpacity = (typeof parent.opacity === 'number' ? parent.opacity : 1) * (parent.spotCur ?? 1);
|
||||||
|
if (parent.hidden && parentOpacity <= 0.02) continue;
|
||||||
|
|
||||||
|
const fx = tx(parent);
|
||||||
|
const fy = ty(parent);
|
||||||
|
const nx = tx(n);
|
||||||
|
const ny = ty(n);
|
||||||
|
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||||
|
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
|
||||||
|
|
||||||
|
const dx = nx - fx;
|
||||||
|
const dy = ny - fy;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
|
||||||
|
const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z;
|
||||||
|
const x1 = fx + ux * fr;
|
||||||
|
const y1 = fy + uy * fr;
|
||||||
|
const x2 = nx - ux * nr;
|
||||||
|
const y2 = ny - uy * nr;
|
||||||
|
const mx = (x1 + x2) / 2;
|
||||||
|
const my = (y1 + y2) / 2;
|
||||||
|
const segLen = Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
const bow = Math.max(7, Math.min(22, segLen * 0.10));
|
||||||
|
const sign = hash01(`${parent.id}|${n.id}|edge`) > 0.5 ? 1 : -1;
|
||||||
|
const desX = mx + (-uy) * bow * sign;
|
||||||
|
const desY = my + ux * bow * sign;
|
||||||
|
const cpx = 2 * desX - mx;
|
||||||
|
const cpy = 2 * desY - my;
|
||||||
|
const relationType = String(ref?.relationType || n.relationType || 'contact');
|
||||||
|
const strength = Math.max(0, Math.min(1, Number(ref?.strength) || Number(n.strength) || 0.5));
|
||||||
|
const opacity = Math.min(childOpacity, parentOpacity);
|
||||||
|
const shine = Boolean(parent.shining && n.shining) && !n.hidden;
|
||||||
|
|
||||||
|
if (shine) {
|
||||||
|
const pnx = -uy;
|
||||||
|
const pny = ux;
|
||||||
|
const amp = Math.min(13, 5 + segLen * 0.05);
|
||||||
|
const bowX = desX - mx;
|
||||||
|
const bowY = desY - my;
|
||||||
|
const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp;
|
||||||
|
const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp;
|
||||||
|
const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp;
|
||||||
|
const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp;
|
||||||
|
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||||
|
parts.push(`<path class="fg-plasma-flare" d="${d}" opacity="${(0.42 * opacity).toFixed(3)}" />`);
|
||||||
|
parts.push(`<path class="fg-plasma-tube" d="${d}" opacity="${(0.85 * opacity).toFixed(3)}" />`);
|
||||||
|
parts.push(`<path class="fg-plasma-core" d="${d}" opacity="${opacity.toFixed(3)}" />`);
|
||||||
|
} else {
|
||||||
|
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||||
|
const col = relationColor(relationType);
|
||||||
|
const haloWidth = (2.6 + strength * 1.4).toFixed(2);
|
||||||
|
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${haloWidth}" stroke-linecap="round" opacity="${(0.22 * opacity).toFixed(2)}" />`);
|
||||||
|
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="1.2" stroke-linecap="round" opacity="${(0.70 * opacity).toFixed(2)}" />`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edgesSvg.innerHTML = parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEdges() {
|
function renderEdges() {
|
||||||
|
if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) {
|
||||||
|
renderHistoryEdges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const focus = nodes.find((n) => n.id === focusId);
|
const focus = nodes.find((n) => n.id === focusId);
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
edgesSvg.innerHTML = '';
|
edgesSvg.innerHTML = '';
|
||||||
@@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
|
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
|
||||||
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
|
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
|
||||||
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
|
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
|
||||||
const shine = Boolean(n.shining) && !n.hidden;
|
const shine = Boolean(parent.shining && n.shining) && !n.hidden;
|
||||||
const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом
|
const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом
|
||||||
const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути
|
const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути
|
||||||
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||||
@@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let ax = 0;
|
let ax = 0;
|
||||||
let ay = 0;
|
let ay = 0;
|
||||||
|
|
||||||
if (n.isFocus) {
|
if (n.fixedLayout) {
|
||||||
|
// Историческая карта уже получила приоритетную раскладку от network-view. Не разрушаем её
|
||||||
|
// общей радиальной физикой: только мягко возвращаем узел к назначенной точке после жестов/анимаций.
|
||||||
|
ax += K_FIXED_LAYOUT * (n.tx - n.x);
|
||||||
|
ay += K_FIXED_LAYOUT * (n.ty - n.y);
|
||||||
|
} else if (n.isFocus) {
|
||||||
// пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается)
|
// пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается)
|
||||||
ax += K_FOCUS * (0 - n.x);
|
ax += K_FOCUS * (0 - n.x);
|
||||||
ay += K_FOCUS * (0 - n.y);
|
ay += K_FOCUS * (0 - n.y);
|
||||||
@@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let dist2 = dx * dx + dy * dy;
|
let dist2 = dx * dx + dy * dy;
|
||||||
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
||||||
const dist = Math.sqrt(dist2);
|
const dist = Math.sqrt(dist2);
|
||||||
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
|
|
||||||
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
|
|
||||||
const ex = Math.max(n.expandP || 0, m.expandP || 0);
|
const ex = Math.max(n.expandP || 0, m.expandP || 0);
|
||||||
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
|
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
|
||||||
ax += (dx / dist) * f;
|
ax += (dx / dist) * f;
|
||||||
@@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
|
|
||||||
const visiblePeers = [];
|
const visiblePeers = [];
|
||||||
nodes.forEach((n) => {
|
nodes.forEach((n) => {
|
||||||
if (n.isFocus) { n.hidden = false; return; }
|
if (n.isFocus || n.keepVisible) { n.hidden = false; return; }
|
||||||
n.hidden = !pred(n);
|
n.hidden = !pred(n);
|
||||||
n.vx = 0;
|
n.vx = 0;
|
||||||
n.vy = 0;
|
n.vy = 0;
|
||||||
@@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
};
|
};
|
||||||
|
|
||||||
const focus = nodes.find((n) => n.isFocus);
|
const focus = nodes.find((n) => n.isFocus);
|
||||||
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
|
if (focus) apply(focus, focus.tx || 0, focus.ty || 0, FOCUS_SCALE, 1);
|
||||||
|
|
||||||
applyOrbitTargets(visiblePeers);
|
const fixedLayout = visiblePeers.some((n) => n.fixedLayout) || nodes.some((n) => n.fixedLayout);
|
||||||
|
if (!fixedLayout) applyOrbitTargets(visiblePeers);
|
||||||
visiblePeers.forEach((n) => {
|
visiblePeers.forEach((n) => {
|
||||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||||
apply(n, n.tx, n.ty, sc, 1);
|
apply(n, n.tx, n.ty, sc, 1);
|
||||||
@@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// инерция панорамирования (kinematic): камера докатывается с трением
|
// инерция панорамирования (kinematic): камера докатывается с трением
|
||||||
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
||||||
if (panActive) {
|
if (panActive) {
|
||||||
camX += panVelX;
|
const proposedX = camX + panVelX;
|
||||||
camY += panVelY;
|
const proposedY = camY + panVelY;
|
||||||
|
const clamped = clampCameraPosition(proposedX, proposedY);
|
||||||
|
camX = clamped.x;
|
||||||
|
camY = clamped.y;
|
||||||
|
if (camX !== proposedX) panVelX = 0;
|
||||||
|
if (camY !== proposedY) panVelY = 0;
|
||||||
panVelX *= PAN_FRICTION;
|
panVelX *= PAN_FRICTION;
|
||||||
panVelY *= PAN_FRICTION;
|
panVelY *= PAN_FRICTION;
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
@@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const newCamY = camStartY + dy;
|
const newCamY = camStartY + dy;
|
||||||
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
|
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
|
||||||
panVelY = newCamY - camY;
|
panVelY = newCamY - camY;
|
||||||
camX = newCamX;
|
const clamped = clampCameraPosition(newCamX, newCamY);
|
||||||
camY = newCamY;
|
camX = clamped.x;
|
||||||
|
camY = clamped.y;
|
||||||
|
if (camX !== newCamX) panVelX = 0;
|
||||||
|
if (camY !== newCamY) panVelY = 0;
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
|
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
|
||||||
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
||||||
@@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
viewH = stage.clientHeight || window.innerHeight;
|
viewH = stage.clientHeight || window.innerHeight;
|
||||||
centerX = viewW / 2;
|
centerX = viewW / 2;
|
||||||
centerY = viewH / 2;
|
centerY = viewH / 2;
|
||||||
|
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||||
|
applyWorldTransform();
|
||||||
renderEdges();
|
renderEdges();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
||||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||||
freezeGraph();
|
freezeGraph();
|
||||||
|
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||||
|
applyWorldTransform();
|
||||||
|
renderEdges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80)
|
boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80)
|
||||||
@@ -1617,8 +1788,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// старый узел нового фокуса (если был) — фокус глайдит из его позиции
|
// старый узел нового фокуса (если был) — фокус глайдит из его позиции
|
||||||
const focusOld = oldById.get(String(newFocusId));
|
const focusOld = oldById.get(String(newFocusId));
|
||||||
|
|
||||||
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
// В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят
|
||||||
spawnGhost();
|
// в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём.
|
||||||
|
if (!nextModel?.preserveHistory) spawnGhost();
|
||||||
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
||||||
|
|
||||||
focusId = String(newFocusId);
|
focusId = String(newFocusId);
|
||||||
@@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
pendingFocusOrigin = null;
|
pendingFocusOrigin = null;
|
||||||
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
|
// Новый центр меняет положение карты, но пользовательский масштаб сохраняем.
|
||||||
|
// Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком.
|
||||||
|
diveTargetId = null; surfacing = false;
|
||||||
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
|
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
|
||||||
updateBadges(); // бейджи-счётчики связей под новый набор
|
updateBadges(); // бейджи-счётчики связей под новый набор
|
||||||
updateA11y(); // текстовый список графа для скринридеров
|
updateA11y(); // текстовый список графа для скринридеров
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -1574,13 +1574,15 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
async getChannelMessages(channel, limit = null, sort = 'asc', login = '') {
|
||||||
const normalizedChannel = {
|
const normalizedChannel = {
|
||||||
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
||||||
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
||||||
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
||||||
};
|
};
|
||||||
const payload = { channel: normalizedChannel, limit, sort };
|
const payload = { channel: normalizedChannel, sort };
|
||||||
|
const cleanLimit = Number(limit);
|
||||||
|
if (Number.isFinite(cleanLimit) && cleanLimit > 0) payload.limit = Math.trunc(cleanLimit);
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetChannelMessages', payload);
|
const response = await this.ws.request('GetChannelMessages', payload);
|
||||||
@@ -1595,6 +1597,17 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageLikes(message) {
|
||||||
|
const normalizedMessage = {
|
||||||
|
blockchainName: String(message?.blockchainName || '').trim(),
|
||||||
|
blockNumber: Number(message?.blockNumber),
|
||||||
|
blockHash: String(message?.blockHash || '').trim(),
|
||||||
|
};
|
||||||
|
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage });
|
||||||
|
if (response.status !== 200) throw opError('GetMessageLikes', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
||||||
const normalizedMessage = {
|
const normalizedMessage = {
|
||||||
blockchainName: String(message?.blockchainName || '').trim(),
|
blockchainName: String(message?.blockchainName || '').trim(),
|
||||||
@@ -2944,6 +2957,12 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserCounters() {
|
||||||
|
const response = await this.ws.request('GetUserCounters', {});
|
||||||
|
if (response.status !== 200) throw opError('GetUserCounters', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getNotifications(countsOnly = false) {
|
async getNotifications(countsOnly = false) {
|
||||||
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
||||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
@@ -3039,6 +3058,55 @@ 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,
|
||||||
@@ -3065,8 +3133,7 @@ export class AuthService {
|
|||||||
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
||||||
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
||||||
|
|
||||||
const preimage = [
|
const preimage = 'SHiNe/UserSettings:' + [
|
||||||
'SHiNe/UserSettings:',
|
|
||||||
escapeUserSettingPart(cleanLogin),
|
escapeUserSettingPart(cleanLogin),
|
||||||
String(cleanSettingType),
|
String(cleanSettingType),
|
||||||
escapeUserSettingPart(cleanSettingKey),
|
escapeUserSettingPart(cleanSettingKey),
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
outgoingTempSeq: 1,
|
outgoingTempSeq: 1,
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
notificationUnreadTotal: 0,
|
notificationUnreadTotal: 0,
|
||||||
|
userCounters: { dmUnreadCount: 0, channelsUnreadCount: 0, notificationsUnreadCount: 0, notifications: { replies: 0, connections: 0, events: 0 } },
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
isAuthorized: storedLocalDemo,
|
isAuthorized: storedLocalDemo,
|
||||||
@@ -1312,6 +1313,7 @@ export async function closeSavedProfile(login) {
|
|||||||
if (!next) {
|
if (!next) {
|
||||||
setActiveProfileLoginRaw('');
|
setActiveProfileLoginRaw('');
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
return { closed: true, nextProfile: null };
|
return { closed: true, nextProfile: null };
|
||||||
}
|
}
|
||||||
setActiveProfileLoginRaw(next.login);
|
setActiveProfileLoginRaw(next.login);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
|
position: relative;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
padding: 4px 3px 2px;
|
padding: 4px 3px 2px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -683,3 +683,146 @@
|
|||||||
color: #ffb7c5;
|
color: #ffb7c5;
|
||||||
background: rgba(var(--shine-action-blue-rgb), 0.12);
|
background: rgba(var(--shine-action-blue-rgb), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Channel message likes: compact popup + full user list modal. */
|
||||||
|
.channel-like-popup-layer,
|
||||||
|
.channel-likes-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1600;
|
||||||
|
background: rgba(5, 9, 18, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1601;
|
||||||
|
left: 50%;
|
||||||
|
top: clamp(72px, 18vh, 180px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(20, 27, 44, 0.96);
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-popup__title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-popup__counts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-count {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-count b { font-size: 18px; }
|
||||||
|
.channel-like-count span { font-size: 10px; opacity: .72; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.channel-like-popup__total {
|
||||||
|
padding: 10px 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-popup__action,
|
||||||
|
.channel-like-popup__close {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-top: 7px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-popup__close { opacity: .72; }
|
||||||
|
|
||||||
|
.channel-likes-modal-overlay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(5, 9, 18, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-modal {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: min(720px, calc(100vh - 28px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(18, 25, 42, 0.98);
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-modal__header h3 { margin: 0; }
|
||||||
|
.channel-likes-modal__close { font-size: 24px; min-width: 38px; min-height: 38px; }
|
||||||
|
|
||||||
|
.channel-likes-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-tabs > button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-tabs > button.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-tabs span { margin-left: 3px; opacity: .68; }
|
||||||
|
|
||||||
|
.channel-likes-modal__status {
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-likes-modal__status.is-error { opacity: 1; }
|
||||||
|
|
||||||
|
.channel-likes-user-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-like-user-row {
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1517,3 +1517,61 @@
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Channel row realtime layout: time top-right, unread bottom-right ===== */
|
||||||
|
.channels-screen--list .channel-row-main {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-title-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-title-line .channel-row-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-title-line .channel-row-time {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
max-width: max-content;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.12;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-preview-line {
|
||||||
|
display: block;
|
||||||
|
padding-right: 42px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row-controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 7px;
|
||||||
|
bottom: 6px;
|
||||||
|
top: auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen--list .channel-row .channel-row-controls > .channel-row-count {
|
||||||
|
position: static;
|
||||||
|
margin: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|||||||
@@ -722,3 +722,16 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ===== Personal chat row layout: time top-right, unread bottom-right ===== */
|
||||||
|
.dm-list-screen .dm-row-time {
|
||||||
|
grid-row: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-list-screen .dm-unread-badge,
|
||||||
|
.dm-list-screen .dm-row-meta-spacer {
|
||||||
|
grid-row: 2;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,24 @@
|
|||||||
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
|
||||||
|
масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */
|
||||||
|
.fg-official-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: -2%;
|
||||||
|
bottom: 0;
|
||||||
|
width: 15%;
|
||||||
|
height: 15%;
|
||||||
|
min-width: 8px;
|
||||||
|
min-height: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
|
||||||
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
background: linear-gradient(165deg, #785038, #5f3e2c);
|
background: linear-gradient(165deg, #785038, #5f3e2c);
|
||||||
border-color: rgba(255, 194, 143, 0.6);
|
border-color: rgba(255, 194, 143, 0.6);
|
||||||
@@ -241,15 +259,17 @@
|
|||||||
.fg-node.is-pressed .node-dot { transform: none; }
|
.fg-node.is-pressed .node-dot { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
/* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути
|
||||||
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
|
от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */
|
||||||
визуально сияющий пользователь всегда остаётся явно сияющим. */
|
|
||||||
.fg-node.is-shine .node-dot {
|
.fg-node.is-shine .node-dot {
|
||||||
border-color: rgba(150, 240, 255, 0.62);
|
border-color: rgba(150, 240, 255, 0.62);
|
||||||
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
box-shadow:
|
||||||
|
0 0 8.3px rgba(146, 243, 255, 0.57),
|
||||||
|
0 0 18.3px rgba(121, 235, 255, 0.39),
|
||||||
|
0 0 33px rgba(100, 220, 255, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */
|
/* Статичный размытый ореол позади аватарки — та же промежуточная интенсивность ~2/3. */
|
||||||
.fg-node.is-shine .node-dot::before {
|
.fg-node.is-shine .node-dot::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -259,34 +279,8 @@
|
|||||||
filter: url(#fg-shine-glow);
|
filter: url(#fg-shine-glow);
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: fg-shine-halo 3.6s ease-in-out infinite;
|
transform: scale(1.07);
|
||||||
}
|
opacity: 0.91;
|
||||||
|
|
||||||
/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */
|
|
||||||
@keyframes fg-shine-glow {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 7px rgba(138, 239, 255, 0.48),
|
|
||||||
0 0 15px rgba(118, 232, 255, 0.32),
|
|
||||||
0 0 27px rgba(100, 220, 255, 0.19);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 9px rgba(150, 245, 255, 0.62),
|
|
||||||
0 0 20px rgba(122, 236, 255, 0.42),
|
|
||||||
0 0 36px rgba(100, 220, 255, 0.26);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
|
||||||
@keyframes fg-shine-halo {
|
|
||||||
0%, 100% { transform: scale(0.98); opacity: 0.72; }
|
|
||||||
50% { transform: scale(1.12); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.fg-node.is-shine .node-dot { animation: none; }
|
|
||||||
.fg-node.is-shine .node-dot::before { animation: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */
|
/* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */
|
||||||
@@ -524,37 +518,11 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
|
/* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */
|
||||||
|
|
||||||
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
|
|
||||||
.fg-node-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
right: -2px;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(16, 24, 40, 0.92);
|
|
||||||
border: 1px solid rgba(150, 200, 255, 0.5);
|
|
||||||
color: #d9ecff;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 14px;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
.fg-node.is-focus .fg-node-badge {
|
|
||||||
background: rgba(61, 196, 223, 0.95);
|
|
||||||
border-color: rgba(220, 245, 255, 0.8);
|
|
||||||
color: #06131c;
|
|
||||||
}
|
|
||||||
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
|
|
||||||
|
|
||||||
/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
|
/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
|
||||||
box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */
|
box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */
|
||||||
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
|
.fg-node.is-focus:not(.is-shine) .node-dot { box-shadow: none; }
|
||||||
|
|
||||||
/* Строка поиска (оверлей вверху, под панелью фильтров) */
|
/* Строка поиска (оверлей вверху, под панелью фильтров) */
|
||||||
.fg-search {
|
.fg-search {
|
||||||
|
|||||||
Reference in New Issue
Block a user