SHA256
Compare commits
17
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
e0295eebde | ||
|
|
ebc9143593 |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
+3
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
public static final int SCHEMA_VERSION_19 = 19;
|
||||
public static final int SCHEMA_VERSION_20 = 20;
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -57,6 +58,7 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -206,6 +208,10 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_21;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -2009,7 +2029,7 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,21,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
+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.Net_GetChannelMessages_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_GetGroupDialog_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_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
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_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
@@ -208,9 +214,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
@@ -299,9 +308,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_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("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
|
||||
+7
-1
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -586,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
}
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
@@ -19,6 +21,8 @@ import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
@@ -44,6 +48,7 @@ public final class BlockchainWriter {
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
@@ -58,7 +63,7 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -75,6 +80,7 @@ public final class BlockchainWriter {
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
@@ -108,6 +114,29 @@ public final class BlockchainWriter {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
@@ -142,6 +171,17 @@ public final class BlockchainWriter {
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
|
||||
+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 {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
boolean bounded = limit > 0;
|
||||
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
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
""" + order + (bounded ? " LIMIT ?" : "");
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||
ps.setInt(9, lineCode);
|
||||
ps.setInt(10, limit);
|
||||
if (bounded) ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
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");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
// limit is optional. null/0 means "return the whole channel".
|
||||
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||
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());
|
||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
if (!asc) {
|
||||
java.util.Collections.reverse(posts);
|
||||
}
|
||||
if (posts.size() > limit) {
|
||||
if (limit > 0 && posts.size() > 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", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: 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);
|
||||
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 {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private long readCount;
|
||||
private int unreadCount;
|
||||
private boolean readStateInitialized;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public int getMessagesCount() { return 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 void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SetChannelReadState_Request extends Net_Request {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long time_ms;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getClient_key() { return client_key; }
|
||||
public void setClient_key(String client_key) { this.client_key = client_key; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SetChannelReadState_Response extends Net_Response {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long unread_count;
|
||||
private Long time_ms;
|
||||
private Boolean applied;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getUnread_count() { return unread_count; }
|
||||
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public Boolean getApplied() { return applied; }
|
||||
public void setApplied(Boolean applied) { this.applied = applied; }
|
||||
}
|
||||
+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.handlers.JsonMessageHandler;
|
||||
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.WireCodes;
|
||||
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");
|
||||
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); }
|
||||
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()); }
|
||||
}
|
||||
}
|
||||
|
||||
+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_Response;
|
||||
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_Response;
|
||||
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();
|
||||
if (status.applied()) {
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
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_Response;
|
||||
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_Response;
|
||||
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();
|
||||
if (pairStatus.applied()) {
|
||||
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) {
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.0
|
||||
server.version=1.10.0
|
||||
client.version=1.12.12
|
||||
server.version=1.10.4
|
||||
|
||||
+18
-1
@@ -123,6 +123,23 @@ find_offline_gradle_zip() {
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
@@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||
else
|
||||
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
|
||||
exit 4
|
||||
fi
|
||||
|
||||
@@ -14,18 +14,24 @@
|
||||
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`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,6 +112,9 @@
|
||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 90,
|
||||
"readCount": 0,
|
||||
"unreadCount": 0,
|
||||
"readStateInitialized": false,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||
"text": "актуальный текст",
|
||||
@@ -140,6 +149,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
@@ -265,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
|
||||
|
||||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||
|
||||
@@ -288,7 +355,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 4) GetChannelsCounters
|
||||
## 6) GetChannelsCounters
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -320,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
|
||||
```json
|
||||
@@ -360,7 +489,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 6) GetGroupDialog
|
||||
## 9) GetGroupDialog
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -413,8 +542,10 @@
|
||||
- `user_not_found`
|
||||
- `channel_not_found`
|
||||
- `message_not_found`
|
||||
- `limit_too_large`
|
||||
- `bad_limit`
|
||||
- `channel_name_already_exists`
|
||||
- `CHANNEL_NOT_FOLLOWED`
|
||||
- `CHANNEL_NOT_FOUND`
|
||||
- `internal_error`
|
||||
|
||||
---
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||
- `GetChannelMessages` — сообщения конкретного канала.
|
||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
|
||||
- `SetChannelReadState` — подписанный watermark чтения канала.
|
||||
|
||||
2. **UI вкладки Каналы**:
|
||||
- при открытии пытается загрузить реальный feed с сервера;
|
||||
@@ -33,12 +35,16 @@
|
||||
1. Вызвать `ListSubscriptionsFeed`.
|
||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||
4. Для первого `messages[0]` вызвать `GetMessageLikes`.
|
||||
5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`.
|
||||
|
||||
### Ошибки
|
||||
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||
4. `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) Что смотреть в ответах
|
||||
@@ -101,6 +139,8 @@
|
||||
- у каждой записи есть:
|
||||
- `channel.channelRoot.blockNumber`,
|
||||
- `messagesCount`,
|
||||
- `unreadCount`,
|
||||
- `readStateInitialized`,
|
||||
- `lastMessage` (может быть null, если сообщений нет).
|
||||
|
||||
### GetChannelMessages
|
||||
@@ -115,6 +155,16 @@
|
||||
- у узлов должны быть версии и счетчики.
|
||||
- у каждого узла дополнительно может приходить `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`.
|
||||
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
|
||||
|
||||
@@ -46,8 +46,10 @@
|
||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||
| `GetChannelMessages` | `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 |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
|
||||
@@ -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 ускоряет отображение, но после переподключения клиент запрашивает
|
||||
историю у своего единственного access-сервера.
|
||||
|
||||
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
|
||||
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
|
||||
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
|
||||
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
|
||||
видимого чтения/receipt отмечает его прочитанным.
|
||||
|
||||
## 9. Подтверждения прочтения
|
||||
|
||||
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||
@@ -204,6 +210,13 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||
|
||||
## Мультипрофильный клиент (UI, 2026-09-03)
|
||||
- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM.
|
||||
- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна.
|
||||
- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие.
|
||||
- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным.
|
||||
- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались.
|
||||
|
||||
## UI: видимость пустого диалога после DeleteConversation
|
||||
|
||||
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
|
||||
|
||||
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
|
||||
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
|
||||
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
|
||||
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
|
||||
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
@@ -357,6 +363,9 @@ ReadReceiptBody_v1_0
|
||||
## Примечание UI списка чатов (2026-08-28)
|
||||
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||
|
||||
## Примечание о мультипрофиле (2026-09-03)
|
||||
Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`).
|
||||
|
||||
## UI-семантика `type=7/8` в списке диалогов
|
||||
|
||||
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||
|
||||
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)` убраны только из формы создания новой записи. Их константы и обработчики чтения сохранены для совместимости со старыми данными.
|
||||
+1278
-37
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => {
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const existing = allClients.find((client) => {
|
||||
try {
|
||||
return client.url.includes('/index.html') || client.url.endsWith('/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const existing = allClients[0] || null;
|
||||
|
||||
const openUrlBase = './index.html';
|
||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||
const openUrl = (action === 'accept' || action === 'decline')
|
||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||
: openUrlBase;
|
||||
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
} else {
|
||||
existing.postMessage({
|
||||
type: 'SHINE_NOTIFICATION_CLICK',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
await existing.focus();
|
||||
|
||||
+4
-2
@@ -18,7 +18,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -43,7 +43,9 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<div id="topbar-slot" class="topbar-slot" hidden></div>
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="composer-slot" class="composer-slot" hidden></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot" hidden></div>
|
||||
<div class="app-shell-fade app-shell-fade--top" aria-hidden="true"></div>
|
||||
<div class="app-shell-fade app-shell-fade--bottom" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script>
|
||||
|
||||
+237
-33
@@ -33,6 +33,9 @@ import {
|
||||
addAppLogEntry,
|
||||
authorizeSession,
|
||||
hydrateMessagesFromStore,
|
||||
getSavedProfiles,
|
||||
closeSavedProfile,
|
||||
switchToSavedProfile,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setSessionAuthorizedHandler,
|
||||
@@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
@@ -132,6 +136,7 @@ const routes = {
|
||||
queue: publicSupportQueueView,
|
||||
'profile-view': profileView,
|
||||
'profile-edit-view': profileEditView,
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'access-servers-view': accessServersView,
|
||||
@@ -213,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||
'settings-view',
|
||||
'profiles-view',
|
||||
]);
|
||||
|
||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||
@@ -393,14 +399,88 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
syncDebug();
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
const MANAGED_SHELL_CLASSES = [
|
||||
'app-shell--top-fade',
|
||||
'app-shell--bottom-fade',
|
||||
'app-shell--bottom-fade-composer',
|
||||
'app-shell--bottom-fade-toolbar',
|
||||
'app-shell--fade-edge',
|
||||
'app-shell--content-under-topbar',
|
||||
'app-shell--content-under-bottom',
|
||||
'app-shell--scroll-nested',
|
||||
'app-shell--scroll-locked',
|
||||
'app-shell--scrollbar-hidden',
|
||||
];
|
||||
|
||||
const MANAGED_SCREEN_CLASSES = [
|
||||
'no-app-chrome',
|
||||
'preauth-flow',
|
||||
'filled-action-buttons',
|
||||
'settings-bordered-actions',
|
||||
];
|
||||
|
||||
const DEFAULT_SHELL_MODE = Object.freeze({
|
||||
topFade: true,
|
||||
bottomFade: false,
|
||||
bottomFadeAnchor: 'composer',
|
||||
fadeProfile: 'standard',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: false,
|
||||
scrollContainer: 'screen',
|
||||
scrollbar: 'auto',
|
||||
});
|
||||
|
||||
function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||
const source = mode && typeof mode === 'object' ? mode : {};
|
||||
const normalized = { ...DEFAULT_SHELL_MODE, ...source };
|
||||
if (!showAppChrome) {
|
||||
normalized.topFade = false;
|
||||
normalized.bottomFade = false;
|
||||
normalized.contentUnderTopbar = false;
|
||||
normalized.contentUnderBottom = false;
|
||||
}
|
||||
normalized.bottomFadeAnchor = normalized.bottomFadeAnchor === 'toolbar' ? 'toolbar' : 'composer';
|
||||
normalized.fadeProfile = normalized.fadeProfile === 'edge' ? 'edge' : 'standard';
|
||||
normalized.scrollContainer = ['screen', 'nested', 'locked'].includes(normalized.scrollContainer)
|
||||
? normalized.scrollContainer
|
||||
: 'screen';
|
||||
normalized.scrollbar = normalized.scrollbar === 'hidden' ? 'hidden' : 'auto';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applyShellMode(mode, showAppChrome = true) {
|
||||
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
|
||||
const normalized = normalizeShellMode(mode, showAppChrome);
|
||||
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-toolbar', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'toolbar');
|
||||
appShellEl.classList.toggle('app-shell--fade-edge', normalized.fadeProfile === 'edge');
|
||||
appShellEl.classList.toggle('app-shell--content-under-topbar', Boolean(normalized.contentUnderTopbar));
|
||||
appShellEl.classList.toggle('app-shell--content-under-bottom', Boolean(normalized.contentUnderBottom));
|
||||
appShellEl.classList.toggle('app-shell--scroll-nested', normalized.scrollContainer === 'nested');
|
||||
appShellEl.classList.toggle('app-shell--scroll-locked', normalized.scrollContainer === 'locked');
|
||||
appShellEl.classList.toggle('app-shell--scrollbar-hidden', normalized.scrollbar === 'hidden');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resetShellMode() {
|
||||
appShellEl?.classList.remove(...MANAGED_SHELL_CLASSES);
|
||||
}
|
||||
|
||||
function resetManagedScreenClasses() {
|
||||
screenEl?.classList.remove(...MANAGED_SCREEN_CLASSES);
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
slotEl.hidden = true;
|
||||
if (presenceClass) appShellEl?.classList.remove(presenceClass);
|
||||
setShellMetricVar(cssVarName, 0);
|
||||
}
|
||||
|
||||
function mountSlot(slotEl, cssVarName, node) {
|
||||
function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
if (node instanceof Node) {
|
||||
@@ -409,60 +489,90 @@ function mountSlot(slotEl, cssVarName, node) {
|
||||
} else {
|
||||
slotEl.hidden = true;
|
||||
}
|
||||
if (presenceClass) appShellEl?.classList.toggle(presenceClass, !slotEl.hidden);
|
||||
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
|
||||
}
|
||||
|
||||
function createChromeController(showAppChrome) {
|
||||
function createChromeController(showAppChrome, initialShellMode = {}) {
|
||||
let topbarNode = null;
|
||||
let composerNode = null;
|
||||
|
||||
const cleanupOwnedNode = (node) => {
|
||||
if (node && typeof node.cleanup === 'function') node.cleanup();
|
||||
};
|
||||
let shellMode = normalizeShellMode(initialShellMode, showAppChrome);
|
||||
let disposed = false;
|
||||
|
||||
const apply = () => {
|
||||
if (disposed) return;
|
||||
applyShellMode(shellMode, showAppChrome);
|
||||
if (!showAppChrome) {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
return;
|
||||
}
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode);
|
||||
mountSlot(composerEl, '--composer-height', composerNode);
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode, 'app-shell--has-topbar');
|
||||
mountSlot(composerEl, '--composer-height', composerNode, 'app-shell--has-composer');
|
||||
topbarHeightObserver?.sync?.();
|
||||
composerHeightObserver?.sync?.();
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
return {
|
||||
setTopbar(node = null) {
|
||||
topbarNode = node instanceof Node ? node : null;
|
||||
const nextTopbar = node instanceof Node ? node : null;
|
||||
if (topbarNode && topbarNode !== nextTopbar) cleanupOwnedNode(topbarNode);
|
||||
topbarNode = nextTopbar;
|
||||
apply();
|
||||
},
|
||||
setComposer(node = null) {
|
||||
composerNode = node instanceof Node ? node : null;
|
||||
const nextComposer = node instanceof Node ? node : null;
|
||||
if (composerNode && composerNode !== nextComposer) cleanupOwnedNode(composerNode);
|
||||
composerNode = nextComposer;
|
||||
apply();
|
||||
},
|
||||
setShellMode(nextMode = {}) {
|
||||
shellMode = normalizeShellMode({ ...shellMode, ...(nextMode || {}) }, showAppChrome);
|
||||
apply();
|
||||
},
|
||||
clear() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
apply();
|
||||
},
|
||||
suspend() {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
},
|
||||
resume() {
|
||||
apply();
|
||||
},
|
||||
dispose() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
disposed = true;
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
resetShellMode();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearKeepAliveEntries() {
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
try {
|
||||
@@ -745,6 +855,77 @@ function consumeCallPushActionFromUrlIfAny() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function pushTargetLogin(payload = {}) {
|
||||
return String(payload?.toLogin || '').trim();
|
||||
}
|
||||
|
||||
function pushTargetPath(payload = {}) {
|
||||
const kind = String(payload?.kind || '').trim();
|
||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||
if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`;
|
||||
return '/profile';
|
||||
}
|
||||
|
||||
function savedProfileExists(login) {
|
||||
const normalized = String(login || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) {
|
||||
const targetLogin = pushTargetLogin(payload);
|
||||
const currentLogin = String(state.session.login || '').trim();
|
||||
if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true;
|
||||
if (!savedProfileExists(targetLogin)) {
|
||||
showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = String(payload?.kind || '').trim();
|
||||
const question = kind === 'incoming_call'
|
||||
? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?`
|
||||
: `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`;
|
||||
if (!window.confirm(question)) return false;
|
||||
|
||||
try {
|
||||
await switchToSavedProfile(targetLogin);
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
savePendingCallPushAction(action, payload);
|
||||
window.location.assign(pushTargetPath(payload));
|
||||
} else {
|
||||
window.location.assign(pushTargetPath(payload));
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationClick(payload = {}) {
|
||||
const canOpen = await ensurePushTargetProfile(payload);
|
||||
if (!canOpen) return;
|
||||
const path = pushTargetPath(payload);
|
||||
navigate(path.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function consumeNotificationOpenFromUrlIfAny() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const rawPayload = String(params.get('pushOpenPayload') || '');
|
||||
if (!rawPayload) return null;
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {}
|
||||
params.delete('pushOpenPayload');
|
||||
const nextQuery = params.toString();
|
||||
window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingCallPushActionIfPossible() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
const pending = loadPendingCallPushAction();
|
||||
@@ -1167,14 +1348,15 @@ function renderPageFailureFallback(pageId, error) {
|
||||
wrap.append(card);
|
||||
screenEl.append(wrap);
|
||||
|
||||
resetManagedScreenClasses();
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
@@ -1197,7 +1379,8 @@ function renderApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
||||
const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) {
|
||||
navigate('messages-list');
|
||||
return;
|
||||
}
|
||||
@@ -1214,10 +1397,23 @@ function renderApp() {
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Сначала полностью очищаем page-owned UI предыдущего маршрута, затем
|
||||
// применяем новый shell mode и только после этого монтируем следующий экран.
|
||||
screenEl.innerHTML = '';
|
||||
const chrome = createChromeController(showAppChrome);
|
||||
resetManagedScreenClasses();
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
try {
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
@@ -1231,16 +1427,6 @@ function renderApp() {
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
@@ -1255,9 +1441,9 @@ function refreshToolbarOnly() {
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
@@ -1286,6 +1472,11 @@ async function tryAutoLogin() {
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
if (result?.nextProfile) {
|
||||
window.location.assign('/profile');
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
@@ -1338,6 +1529,7 @@ async function ensureSessionRuntimeStarted() {
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
if (state.session.isLocalDemo) {
|
||||
@@ -1373,12 +1565,20 @@ async function init() {
|
||||
const action = String(data.action || '').trim().toLowerCase();
|
||||
const payload = data.payload || {};
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
void (async () => {
|
||||
const canHandle = await ensurePushTargetProfile(payload, { action });
|
||||
if (!canHandle) return;
|
||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||
savePendingCallPushAction(action, payload);
|
||||
void processPendingCallPushActionIfPossible();
|
||||
await processPendingCallPushActionIfPossible();
|
||||
})();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'SHINE_NOTIFICATION_CLICK') {
|
||||
void handleNotificationClick(data.payload || {});
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||
|
||||
const payload = data.payload || {};
|
||||
@@ -1411,7 +1611,8 @@ async function init() {
|
||||
}
|
||||
|
||||
authService.onEvent('SessionRevoked', async () => {
|
||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||
});
|
||||
|
||||
authService.onEvent('ForceUiReload', async (evt) => {
|
||||
@@ -1714,6 +1915,9 @@ async function init() {
|
||||
void (async () => {
|
||||
try {
|
||||
await tryAutoLogin();
|
||||
if (initialNotificationOpenPayload) {
|
||||
await handleNotificationClick(initialNotificationOpenPayload);
|
||||
}
|
||||
await hydrateMessagesFromStore();
|
||||
if (!state.session.isLocalDemo) {
|
||||
startConnectionMonitor();
|
||||
|
||||
@@ -1,88 +1,157 @@
|
||||
let activeDropdown = null;
|
||||
|
||||
function normalizeItems(items) {
|
||||
const value = typeof items === 'function' ? items() : items;
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function resolvePlacement({ placement, align }) {
|
||||
if (placement === 'bottom-start' || placement === 'top-start') return 'left';
|
||||
if (placement === 'bottom-end' || placement === 'top-end') return 'right';
|
||||
return align === 'left' ? 'left' : 'right';
|
||||
}
|
||||
|
||||
export function createDropdownMenu({
|
||||
anchorEl,
|
||||
items = [],
|
||||
renderContent = null,
|
||||
className = '',
|
||||
minWidth = 210,
|
||||
offset = 7,
|
||||
align = 'right',
|
||||
placement = 'bottom-end',
|
||||
align = null,
|
||||
leftShift = 0,
|
||||
transparent = false,
|
||||
dimBackground = true,
|
||||
keepAnchorPressed = true,
|
||||
onOpen = null,
|
||||
onClose = null,
|
||||
} = {}) {
|
||||
let portal = null;
|
||||
let menuEl = null;
|
||||
let destroyed = false;
|
||||
|
||||
const close = () => {
|
||||
const setAnchorOpen = (isOpen) => {
|
||||
if (!anchorEl) return;
|
||||
anchorEl.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true';
|
||||
else delete anchorEl.dataset.open;
|
||||
};
|
||||
|
||||
const close = ({ focusAnchor = false } = {}) => {
|
||||
if (!portal) return;
|
||||
portal.remove();
|
||||
portal = null;
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
menuEl = null;
|
||||
if (activeDropdown === api) activeDropdown = null;
|
||||
setAnchorOpen(false);
|
||||
onClose?.();
|
||||
if (focusAnchor) anchorEl?.focus?.();
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!portal || !anchorEl) return;
|
||||
if (!portal || !menuEl || !anchorEl) return;
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = portal.offsetWidth || minWidth;
|
||||
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const menuWidth = menuEl.offsetWidth || minWidth;
|
||||
const side = resolvePlacement({ placement, align });
|
||||
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const desiredLeft = baseLeft - Number(leftShift || 0);
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||
let top = rect.bottom + offset;
|
||||
const menuHeight = portal.offsetHeight || 180;
|
||||
if (top + menuHeight > window.innerHeight - margin) {
|
||||
|
||||
const menuHeight = menuEl.offsetHeight || 180;
|
||||
const prefersTop = String(placement || '').startsWith('top-');
|
||||
let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset;
|
||||
if (!prefersTop && top + menuHeight > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||
} else if (prefersTop && top < margin) {
|
||||
top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset);
|
||||
}
|
||||
portal.style.left = `${Math.round(left)}px`;
|
||||
portal.style.top = `${Math.round(top)}px`;
|
||||
|
||||
menuEl.style.left = `${Math.round(left)}px`;
|
||||
menuEl.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!anchorEl || portal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.style.minWidth = `${minWidth}px`;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.divider) {
|
||||
const appendItems = () => {
|
||||
normalizeItems(items).forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
menu.append(divider);
|
||||
divider.className = 'dropdown-menu__divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
menuEl.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
if (item?.iconHtml) {
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`;
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.disabled = Boolean(item.disabled);
|
||||
|
||||
if (item.iconHtml) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shared-dropdown-menu__icon';
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.innerHTML = item.iconHtml;
|
||||
btn.append(icon);
|
||||
} else if (item?.iconSrc) {
|
||||
button.append(icon);
|
||||
} else if (item.iconSrc) {
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.src = item.iconSrc;
|
||||
icon.alt = '';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon);
|
||||
button.append(icon);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(item?.label || '');
|
||||
btn.append(label);
|
||||
btn.addEventListener('click', (event) => {
|
||||
label.textContent = String(item.label || '');
|
||||
button.append(label);
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
item?.action?.();
|
||||
await item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
menuEl.append(button);
|
||||
});
|
||||
};
|
||||
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
document.body.append(menu);
|
||||
portal = menu;
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
const open = () => {
|
||||
if (destroyed || !anchorEl || portal) return;
|
||||
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
|
||||
|
||||
portal = document.createElement('div');
|
||||
portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`;
|
||||
|
||||
if (dimBackground) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dropdown-backdrop';
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === backdrop) close();
|
||||
});
|
||||
portal.append(backdrop);
|
||||
}
|
||||
|
||||
menuEl = document.createElement('div');
|
||||
menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`;
|
||||
menuEl.setAttribute('role', 'menu');
|
||||
menuEl.style.minWidth = `${minWidth}px`;
|
||||
|
||||
if (typeof renderContent === 'function') {
|
||||
const content = renderContent({ close, menuEl });
|
||||
if (content instanceof Node) menuEl.append(content);
|
||||
} else {
|
||||
appendItems();
|
||||
}
|
||||
|
||||
menuEl.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
menuEl.addEventListener('click', (event) => event.stopPropagation());
|
||||
portal.append(menuEl);
|
||||
document.body.append(portal);
|
||||
activeDropdown = api;
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
};
|
||||
@@ -97,43 +166,49 @@ export function createDropdownMenu({
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
};
|
||||
const onOutsideClick = (event) => {
|
||||
const onOutsidePointerDown = (event) => {
|
||||
if (!portal) return;
|
||||
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!portal || event?.detail?.owner === anchorEl) return;
|
||||
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close();
|
||||
anchorEl?.focus();
|
||||
close({ focusAnchor: true });
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
setAnchorOpen(false);
|
||||
anchorEl?.addEventListener('click', onAnchorClick);
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', position, { passive: true });
|
||||
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||
window.addEventListener('popstate', onNavigation);
|
||||
window.addEventListener('hashchange', onNavigation);
|
||||
window.addEventListener('resize', onViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
const api = {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
position,
|
||||
get isOpen() {
|
||||
return Boolean(portal);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
close();
|
||||
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', position);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
window.removeEventListener('popstate', onNavigation);
|
||||
window.removeEventListener('hashchange', onNavigation);
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('scroll', onViewportChange, true);
|
||||
setAnchorOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'header-left-label';
|
||||
label.textContent = leftLabel;
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
rightActions.forEach((action) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) btn.title = action.title;
|
||||
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.iconNode instanceof Node) {
|
||||
btn.append(action.iconNode);
|
||||
} else {
|
||||
btn.textContent = action.label;
|
||||
}
|
||||
btn.addEventListener('click', action.onClick);
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
@@ -20,16 +20,68 @@ function iconHtml(item) {
|
||||
: `<span>${item.icon}</span>`;
|
||||
}
|
||||
|
||||
function getTotalUnreadMessages() {
|
||||
const chats = Object.values(state.chats || {});
|
||||
let total = 0;
|
||||
chats.forEach((messages) => {
|
||||
if (!Array.isArray(messages)) return;
|
||||
messages.forEach((msg) => {
|
||||
if (msg?.from === 'in' && msg?.unread) total += 1;
|
||||
function normalizeCounters(payload = {}) {
|
||||
const notifications = payload?.notifications || {};
|
||||
const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? (
|
||||
Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0)
|
||||
)) || 0);
|
||||
return {
|
||||
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) {
|
||||
@@ -65,7 +117,8 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const root = document.createElement('nav');
|
||||
root.className = 'toolbar';
|
||||
const active = resolveToolbarActive(currentPageId);
|
||||
const unreadTotal = getTotalUnreadMessages();
|
||||
ensureCountersPushBound();
|
||||
const counters = normalizeCounters(state.userCounters);
|
||||
|
||||
ITEMS.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
@@ -92,21 +145,9 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
}
|
||||
if (isMessages && unreadTotal > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'toolbar-unread-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 (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
if (item.pageId === 'channels-list') {
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
@@ -115,19 +156,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
root.append(btn);
|
||||
});
|
||||
|
||||
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
|
||||
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(() => {});
|
||||
}
|
||||
if (state.session.isAuthorized) void refreshUserCounters();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
if (!target || !(node instanceof Node)) return;
|
||||
target.append(node);
|
||||
}
|
||||
|
||||
function createActionButton(action = {}, cleanupFns) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__action${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) button.title = action.title;
|
||||
if (action.ariaLabel) button.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.id) button.dataset.action = action.id;
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
|
||||
if (action.menu) {
|
||||
const menu = createDropdownMenu({
|
||||
anchorEl: button,
|
||||
transparent: true,
|
||||
dimBackground: true,
|
||||
keepAnchorPressed: true,
|
||||
...(typeof action.menu === 'object' ? action.menu : {}),
|
||||
});
|
||||
cleanupFns.add(() => menu.destroy());
|
||||
} else if (typeof action.onClick === 'function') {
|
||||
button.addEventListener('click', action.onClick);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createTopBar({
|
||||
title = '',
|
||||
className = '',
|
||||
left = null,
|
||||
center = null,
|
||||
back = null,
|
||||
leftLabel = '',
|
||||
actions = [],
|
||||
} = {}) {
|
||||
const cleanupFns = new Set();
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = `topbar${className ? ` ${className}` : ''}`;
|
||||
|
||||
const leftSlot = document.createElement('div');
|
||||
leftSlot.className = 'topbar__left';
|
||||
|
||||
const backAction = back;
|
||||
if (backAction?.visible !== false && typeof backAction?.onClick === 'function') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
leftSlot.append(button);
|
||||
}
|
||||
|
||||
appendNode(leftSlot, left);
|
||||
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'topbar__left-label';
|
||||
label.textContent = leftLabel;
|
||||
leftSlot.append(label);
|
||||
}
|
||||
|
||||
const centerSlot = document.createElement('div');
|
||||
centerSlot.className = 'topbar__center';
|
||||
const resolvedCenter = center;
|
||||
if (resolvedCenter instanceof Node) {
|
||||
centerSlot.append(resolvedCenter);
|
||||
} else {
|
||||
const heading = document.createElement('h1');
|
||||
heading.className = 'topbar__title';
|
||||
heading.textContent = String(title || '');
|
||||
centerSlot.append(heading);
|
||||
}
|
||||
|
||||
const rightSlot = document.createElement('div');
|
||||
rightSlot.className = 'topbar__right';
|
||||
const normalizedActions = actions;
|
||||
normalizedActions.forEach((action) => {
|
||||
rightSlot.append(createActionButton(action, cleanupFns));
|
||||
});
|
||||
|
||||
topbar.append(leftSlot, centerSlot, rightSlot);
|
||||
|
||||
topbar.addCleanup = (cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanupFns.add(cleanup);
|
||||
return cleanup;
|
||||
};
|
||||
topbar.cleanup = () => {
|
||||
for (const cleanup of cleanupFns) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
console.warn('[TopBar] cleanup failed', error);
|
||||
}
|
||||
}
|
||||
cleanupFns.clear();
|
||||
};
|
||||
|
||||
return topbar;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
@@ -259,7 +259,7 @@ function createPasswordModal() {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -427,7 +427,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = items.map((item) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
@${escapeHtml(item.login)}
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
|
||||
</button>`
|
||||
@@ -633,11 +633,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Сервер доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
introCard,
|
||||
listCard,
|
||||
addCard,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
@@ -78,16 +78,14 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Создание канала',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -97,7 +95,7 @@ export function render({ navigate }) {
|
||||
<div class="channel-create-avatar-side">
|
||||
<div class="channel-create-avatar-status-row">
|
||||
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
|
||||
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
<button type="button" class="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
</div>
|
||||
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
@@ -47,16 +47,14 @@ function createDebounced(fn, delayMs = 240) {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -119,7 +117,7 @@ export function render({ navigate }) {
|
||||
rows.slice(0, 8).forEach((login) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = String(login);
|
||||
btn.addEventListener('click', () => {
|
||||
selectedCanonicalLogin = String(login);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
|
||||
@@ -11,16 +11,14 @@ function formatTime(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
|
||||
|
||||
function closeUploadsMenu(controls) {
|
||||
const menu = controls?.querySelector?.('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
function setStatusBadge(node, status, label) {
|
||||
if (!node) return;
|
||||
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
|
||||
@@ -64,25 +60,10 @@ function renderTile(item, index) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack arweave-uploads-screen';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'arweave-uploads-toolbar';
|
||||
controls.innerHTML = `
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -91,7 +72,6 @@ export function render({ navigate }) {
|
||||
|
||||
const uploadFile = async () => {
|
||||
statusLine.textContent = '';
|
||||
closeUploadsMenu(controls);
|
||||
try {
|
||||
await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
@@ -134,42 +114,50 @@ export function render({ navigate }) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
|
||||
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = !menu.hidden;
|
||||
});
|
||||
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
label: '+',
|
||||
title: 'Добавить файл',
|
||||
ariaLabel: 'Добавить файл',
|
||||
className: 'arweave-uploads-add',
|
||||
onClick: () => { void uploadFile(); },
|
||||
},
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню',
|
||||
ariaLabel: 'Меню загрузок',
|
||||
className: 'arweave-uploads-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Добавить файл', action: () => uploadFile() },
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
action: () => {
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
});
|
||||
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Справка',
|
||||
action: () => window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
screen.addEventListener('click', (event) => {
|
||||
if (controls.contains(event.target)) return;
|
||||
closeUploadsMenu(controls);
|
||||
});
|
||||
|
||||
screen.append(controls, statusLine, list);
|
||||
chrome?.setTopbar(topbar);
|
||||
screen.append(statusLine, list);
|
||||
renderHistory();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
@@ -63,9 +63,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = renderHeader({
|
||||
const topbar = createTopBar({
|
||||
title: 'О канале',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (window.history.length > 1) {
|
||||
@@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'meta-muted screen-footer';
|
||||
footer.textContent = 'О канале (channel-about-view)';
|
||||
|
||||
screen.append(card, footer);
|
||||
screen.append(card);
|
||||
|
||||
const renderContent = (channel) => {
|
||||
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -437,7 +437,7 @@ function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, l
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
@@ -471,6 +471,7 @@ function openBlockchainDetailsModal(details) {
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
if (!isActive()) return;
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
|
||||
@@ -495,7 +496,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
@@ -508,7 +509,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
@@ -569,9 +570,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
@@ -589,10 +592,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
@@ -602,7 +606,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
@@ -668,8 +672,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
@@ -716,7 +722,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
@@ -749,16 +755,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
@@ -798,7 +808,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -821,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'message-edited-marker';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
editedMarker.title = 'Открыть историю редактирования';
|
||||
editedMarker.addEventListener('click', (event) => {
|
||||
@@ -844,7 +854,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.className = 'ui-button channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
@@ -865,7 +875,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.className = 'ui-button deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${author}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
@@ -915,7 +925,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item thread-like-btn';
|
||||
likeButton.className = 'ui-button channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
@@ -949,7 +959,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item thread-reply-btn';
|
||||
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
@@ -962,6 +972,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
isActive: handlers.isActive,
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
@@ -969,7 +980,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'channel-action-item thread-share-btn';
|
||||
shareButton.className = 'ui-button channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
@@ -987,7 +998,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
originalButton.className = 'channel-action-item';
|
||||
originalButton.className = 'ui-button channel-action-item';
|
||||
originalButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
@@ -1009,7 +1020,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.className = 'ui-button channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
@@ -1025,13 +1036,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
raw: node,
|
||||
localNumber,
|
||||
msgSubType,
|
||||
}));
|
||||
}), { isActive: handlers.isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.className = 'ui-button channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
@@ -1086,11 +1097,12 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
|
||||
const target = pendingThreadScroll.get(routeKey);
|
||||
if (!target) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (!shouldContinue()) return;
|
||||
if (target === '__LAST_REPLY__') {
|
||||
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
@@ -1108,7 +1120,7 @@ function applyPendingScroll(screen, routeKey) {
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(doScroll, 20);
|
||||
return window.setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
@@ -1120,15 +1132,18 @@ function renderSkeleton(screen) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
let selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
let activeResolvedChannelLabel = channelDisplayName;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
let refresh = () => {};
|
||||
const refreshTimers = new Set();
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
@@ -1136,10 +1151,10 @@ export function render({ navigate, route, chrome }) {
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
@@ -1156,19 +1171,12 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
try {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
}
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (disposed) return;
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
@@ -1191,6 +1199,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const handlers = {
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
@@ -1208,12 +1217,14 @@ export function render({ navigate, route, chrome }) {
|
||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, nextReaction);
|
||||
softHaptic(10);
|
||||
rerender();
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, previousReaction || 'unliked');
|
||||
rerender();
|
||||
void refresh();
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
@@ -1222,24 +1233,27 @@ export function render({ navigate, route, chrome }) {
|
||||
onReply: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Ответ отправлен');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRating: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
if (disposed) return;
|
||||
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
|
||||
.map((row) => {
|
||||
const selectorRow = {
|
||||
@@ -1264,6 +1278,7 @@ export function render({ navigate, route, chrome }) {
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
@@ -1272,6 +1287,7 @@ export function render({ navigate, route, chrome }) {
|
||||
message: target,
|
||||
text,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
showStatus('');
|
||||
@@ -1287,6 +1303,7 @@ export function render({ navigate, route, chrome }) {
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
if (result === 'copied') showToast('Ссылка скопирована');
|
||||
if (result === 'shared') showToast('Ссылка передана');
|
||||
if (result === 'copied' || result === 'shared') softHaptic(10);
|
||||
@@ -1320,33 +1337,69 @@ export function render({ navigate, route, chrome }) {
|
||||
isChannelPost: meta?.isChannelPost === true,
|
||||
channel: selector?.channel || null,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(statusBox);
|
||||
|
||||
const clearContent = () => {
|
||||
for (const timerId of refreshTimers) window.clearTimeout(timerId);
|
||||
refreshTimers.clear();
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
if (child !== statusBox) child.remove();
|
||||
});
|
||||
};
|
||||
|
||||
const clearOwnedModal = () => {
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
if (!modalRoot) return;
|
||||
if (modalRoot.querySelector([
|
||||
'#thread-blockchain-details-modal',
|
||||
'#thread-edit-modal',
|
||||
'#thread-history-modal',
|
||||
'#thread-reply-modal',
|
||||
'#thread-repost-modal',
|
||||
].join(','))) {
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
const trackTimer = (timerId) => {
|
||||
if (timerId) refreshTimers.add(timerId);
|
||||
return timerId;
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
return;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let resolvedMessage = selector.message;
|
||||
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
|
||||
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const allRows = [
|
||||
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
|
||||
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
||||
@@ -1369,6 +1422,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(ownerRaw);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
if (ownerBch) {
|
||||
channel = allRows.find((item) => (
|
||||
@@ -1383,6 +1437,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && ownerLoginFromBch) {
|
||||
try {
|
||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||
channel = ownerRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
||||
@@ -1412,6 +1467,7 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
|
||||
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
@@ -1437,6 +1493,7 @@ export function render({ navigate, route, chrome }) {
|
||||
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
}
|
||||
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
@@ -1453,10 +1510,10 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
seq += 1;
|
||||
return seq;
|
||||
localSeq += 1;
|
||||
return localSeq;
|
||||
};
|
||||
|
||||
let ancestorsWrap = null;
|
||||
@@ -1506,25 +1563,33 @@ export function render({ navigate, route, chrome }) {
|
||||
if (focusWrap) screen.append(focusWrap);
|
||||
screen.append(descendantsWrap);
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
if (!hasPendingScroll && focusWrap) {
|
||||
setTimeout(() => {
|
||||
trackTimer(window.setTimeout(() => {
|
||||
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
|
||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}, 20);
|
||||
}, 20));
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
screen.append(failed);
|
||||
}
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
clearOwnedModal();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
+537
-181
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,12 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const DIARY_CHANNEL_NAME = 'diary';
|
||||
@@ -258,7 +257,7 @@ function renderSuggestions(container, values, onPick) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -528,7 +527,7 @@ function openChannelFinderModal({ navigate }) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value.label;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -730,7 +729,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const title = displayTitle || channelName;
|
||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||
const technicalLabel = `@${ownerLogin}/${channelName}`;
|
||||
const lastMessage = resolveChannelLastMessage(summary);
|
||||
|
||||
return {
|
||||
@@ -945,263 +944,25 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function closeChannelMenu(listState, clearOpenMenuId = true) {
|
||||
if (typeof listState.menuCleanup === 'function') {
|
||||
listState.menuCleanup();
|
||||
}
|
||||
listState.menuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
if (clearOpenMenuId) {
|
||||
listState.openMenuId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeTopChannelsMenu(listState) {
|
||||
if (typeof listState.topMenuCleanup === 'function') {
|
||||
listState.topMenuCleanup();
|
||||
}
|
||||
listState.topMenuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 250;
|
||||
const titleAnchor = document.querySelector('.channels-filter-title');
|
||||
const titleRect = titleAnchor?.getBoundingClientRect?.();
|
||||
let top = (titleRect?.bottom || rect.bottom) + 7;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = TOP_MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
divider.style.margin = '6px 0';
|
||||
menu.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-menu-item';
|
||||
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
closeTopChannelsMenu(listState);
|
||||
item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
});
|
||||
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (event?.detail?.owner === anchorEl) return;
|
||||
closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
listState.topMenuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
};
|
||||
}
|
||||
|
||||
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||||
closeChannelMenu(listState, false);
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 210;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const canToggleSubscription = !channel.isOwnChannel;
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||
|
||||
const actionLabel = document.createElement('span');
|
||||
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||
|
||||
if (canToggleSubscription) {
|
||||
actionLabel.textContent = channel.pending
|
||||
? 'Выполняется...'
|
||||
: channel.isSubscribed
|
||||
? 'Отписаться'
|
||||
: 'Подписаться';
|
||||
actionBtn.disabled = !!channel.pending;
|
||||
|
||||
actionBtn.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
if (channel.pending) return;
|
||||
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
channel.pending = true;
|
||||
actionBtn.disabled = true;
|
||||
actionLabel.textContent = 'Выполняется...';
|
||||
|
||||
const nextSubscribed = !channel.isSubscribed;
|
||||
try {
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: channel.ownerBlockchainName,
|
||||
targetBlockNumber: channel.channelRootBlockNumber,
|
||||
targetBlockHashHex: channel.channelRootBlockHash,
|
||||
unfollow: !nextSubscribed,
|
||||
});
|
||||
|
||||
channel.isSubscribed = nextSubscribed;
|
||||
channel.pending = false;
|
||||
softHaptic(15);
|
||||
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
|
||||
closeChannelMenu(listState);
|
||||
await refreshFeed();
|
||||
} catch (error) {
|
||||
channel.pending = false;
|
||||
actionBtn.disabled = false;
|
||||
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||
rerenderList();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionLabel.textContent = 'Собственный канал';
|
||||
actionBtn.disabled = true;
|
||||
}
|
||||
|
||||
const toggleWrap = document.createElement('div');
|
||||
toggleWrap.className = 'channel-menu-toggle';
|
||||
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.className = 'channel-menu-toggle-label';
|
||||
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
|
||||
toggleBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(toggleBtn);
|
||||
|
||||
channel.notificationsEnabled = !channel.notificationsEnabled;
|
||||
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
|
||||
listState.notificationsState = next;
|
||||
writeChannelNotificationsState(next);
|
||||
|
||||
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
|
||||
softHaptic(10);
|
||||
});
|
||||
|
||||
toggleWrap.append(toggleLabel, toggleBtn);
|
||||
menu.append(actionBtn, toggleWrap);
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowResize = () => {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
listState.menuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMain(channel) {
|
||||
const main = document.createElement('div');
|
||||
main.className = 'channel-row-main';
|
||||
|
||||
const titleLine = document.createElement('div');
|
||||
titleLine.className = 'channel-row-title-line';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.className = 'channel-row-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');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
||||
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -1217,16 +978,13 @@ function renderChannelMain(channel) {
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
previewLine.append(preview, time);
|
||||
previewLine.append(preview);
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title, technical);
|
||||
main.prepend(titleLine, technical);
|
||||
main.append(previewLine, meta);
|
||||
return main;
|
||||
}
|
||||
@@ -1288,17 +1046,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||
if (!silent) renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
setChannelsFeed(null, {});
|
||||
@@ -1323,12 +1072,18 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
} catch {
|
||||
diaryPayload = null;
|
||||
}
|
||||
|
||||
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||
// The server/API implementation is preserved so the feature can be restored later.
|
||||
// See: docs/закомментировано/Личный_дневник.md
|
||||
// let 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);
|
||||
|
||||
listState.channels = toListModel(groups);
|
||||
@@ -1342,6 +1097,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (silent) return;
|
||||
setChannelsFeed(null, {});
|
||||
contentEl.innerHTML = '';
|
||||
|
||||
@@ -1357,39 +1113,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const createSuccessFlash = pullCreateSuccessFlash();
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
menuCleanup: null,
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
align: 'left',
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
@@ -1399,38 +1145,44 @@ export function render({ navigate, route, chrome }) {
|
||||
],
|
||||
});
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
|
||||
const topMenuBtn = document.createElement('button');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
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 = () => {
|
||||
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
|
||||
renderListContent({
|
||||
screen,
|
||||
@@ -1442,30 +1194,25 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
screen.append(contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
rerenderList();
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
unsubscribeCountersChanged();
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
+76
-205
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
@@ -32,7 +33,18 @@ import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } f
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
bottomFadeAnchor: 'composer',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: true,
|
||||
scrollContainer: 'nested',
|
||||
},
|
||||
};
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function menuIconSvg(name) {
|
||||
@@ -49,58 +61,6 @@ function menuIconSvg(name) {
|
||||
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
|
||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||
<span>Показать связи</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Показать профиль</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const layer = root.querySelector('#chat-user-menu-layer');
|
||||
const menu = root.querySelector('.dm-user-identity-menu');
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!menu) return;
|
||||
const width = menu.offsetWidth || 190;
|
||||
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
});
|
||||
|
||||
layer?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === layer) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChatRelationType(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
||||
@@ -178,15 +138,30 @@ function createChatHeaderParts(login, navigate) {
|
||||
};
|
||||
|
||||
renderPeer();
|
||||
identityButton.addEventListener('click', (event) => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||
const identityMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: identityButton,
|
||||
placement: 'bottom-start',
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{
|
||||
label: 'Показать связи',
|
||||
iconSrc: '/assets/SHiNE_connections_blue.svg',
|
||||
action: () => navigate(makeProfileLinksRoute(cleanLogin)),
|
||||
},
|
||||
{
|
||||
label: 'Показать профиль',
|
||||
iconSrc: '/assets/profile-icon-profile.svg',
|
||||
action: () => navigate(makeProfileRoute(cleanLogin)),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
centerNode: identityButton,
|
||||
center: identityButton,
|
||||
updatePeer,
|
||||
getPeer: () => ({ ...currentPeer }),
|
||||
cleanup: () => identityMenu.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -414,91 +389,6 @@ function openMessageActionsMenu({
|
||||
});
|
||||
}
|
||||
|
||||
function openChatActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
showAddContact = false,
|
||||
onCall,
|
||||
onVideoCall,
|
||||
onAddContact,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
|
||||
const menuId = `chat-header-actions-menu-${Date.now()}`;
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true">+</span><span>Добавить в контакты</span></button>` : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = root.querySelector(`#${menuId}`);
|
||||
if (!menu) return;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (menu.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
const left = Math.min(
|
||||
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
|
||||
Math.max(12, viewportWidth - menuRect.width - 12)
|
||||
);
|
||||
const top = Math.min(
|
||||
Math.max(12, Number(anchorY || 0) + 10),
|
||||
Math.max(12, viewportHeight - menuRect.height - 12)
|
||||
);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
|
||||
menu.classList.add('is-visible');
|
||||
});
|
||||
|
||||
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onCall === 'function') await onCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onAddContact === 'function') await onAddContact();
|
||||
});
|
||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
});
|
||||
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onDeleteChat === 'function') await onDeleteChat();
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -853,7 +743,7 @@ function renderLog(
|
||||
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
|
||||
const replyBox = document.createElement('button');
|
||||
replyBox.type = 'button';
|
||||
replyBox.className = 'bubble-reply-preview';
|
||||
replyBox.className = 'ui-button bubble-reply-preview';
|
||||
|
||||
const replyAuthor = document.createElement('div');
|
||||
replyAuthor.className = 'bubble-reply-preview-author';
|
||||
@@ -998,7 +888,6 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
document.body.classList.add('chat-topbar-overlay');
|
||||
const routeChatId = route.params.chatId || 'u1';
|
||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
||||
@@ -1013,7 +902,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||
let historyHasMore = true;
|
||||
let historyLoading = false;
|
||||
let historyNextBeforeTimeMs = 0;
|
||||
@@ -1021,16 +909,8 @@ export function render({ navigate, route, chrome }) {
|
||||
let historyBootstrapped = false;
|
||||
let boundScrollContainer = null;
|
||||
let unreadSeparatorVisible = hasUnreadIncoming;
|
||||
let unreadSeparatorHideTimer = null;
|
||||
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 } = {}) => {
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
@@ -1039,13 +919,9 @@ export function render({ navigate, route, chrome }) {
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
};
|
||||
|
||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorVisible = false;
|
||||
if (rerender) {
|
||||
@@ -1053,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) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog();
|
||||
@@ -1140,10 +1008,10 @@ export function render({ navigate, route, chrome }) {
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
const chatHeader = createTopBar({
|
||||
center: chatHeaderParts.center,
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
actions: [
|
||||
{
|
||||
title: 'Позвонить',
|
||||
ariaLabel: 'Позвонить',
|
||||
@@ -1156,22 +1024,28 @@ export function render({ navigate, route, chrome }) {
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
onClick: (event) => {
|
||||
openChatActionsMenu({
|
||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
|
||||
onCall: () => handleStartCall('audio'),
|
||||
onVideoCall: () => handleStartCall('video'),
|
||||
onAddContact: async () => {
|
||||
menu: {
|
||||
minWidth: 230,
|
||||
items: () => [
|
||||
{ label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') },
|
||||
{ label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') },
|
||||
normalizeChatRelationType(peerRelationType) === 'none'
|
||||
? {
|
||||
label: 'Добавить в контакты',
|
||||
iconHtml: '<span aria-hidden="true">+</span>',
|
||||
action: async () => {
|
||||
try {
|
||||
await addPeerToContacts();
|
||||
} catch (error) {
|
||||
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
}
|
||||
: null,
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
iconHtml: menuIconSvg('clear'),
|
||||
action: () => openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
|
||||
confirmLabel: 'Очистить',
|
||||
@@ -1184,22 +1058,20 @@ export function render({ navigate, route, chrome }) {
|
||||
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
onDeleteChat: async () => {
|
||||
{
|
||||
label: 'Удалить чат',
|
||||
iconHtml: menuIconSvg('delete'),
|
||||
danger: true,
|
||||
action: () => {
|
||||
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
relationType: relationBeforeDelete,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) {
|
||||
await clearConversationHistory();
|
||||
}
|
||||
|
||||
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
|
||||
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
|
||||
// закономерно останется в списке из-за действующей связи.
|
||||
if (deleteHistory) await clearConversationHistory();
|
||||
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||
? ['close_friend', 'friend', 'contact']
|
||||
: ['contact'];
|
||||
@@ -1212,7 +1084,6 @@ export function render({ navigate, route, chrome }) {
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
}
|
||||
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(
|
||||
contactsPayload?.contacts
|
||||
@@ -1228,11 +1099,13 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
chatHeader.addCleanup(chatHeaderParts.cleanup);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
|
||||
@@ -1477,12 +1350,19 @@ export function render({ navigate, route, chrome }) {
|
||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||
const text = safeText.trim();
|
||||
if (!text) return;
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
const editing = activeEdit;
|
||||
const replying = !editing ? activeReply : null;
|
||||
const finalText = editing
|
||||
? `${String(editing?.prefixText || '')}${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);
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
@@ -1735,13 +1615,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleIncomingChatRefresh = async (event) => {
|
||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||
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, () => {
|
||||
renderChatLog({ scrollMode: 'latest' });
|
||||
});
|
||||
@@ -1766,10 +1642,8 @@ export function render({ navigate, route, chrome }) {
|
||||
markAsRead: false,
|
||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (markChatRead(chatId) > 0) {
|
||||
notifyUnreadStateUpdated();
|
||||
@@ -1787,9 +1661,6 @@ export function render({ navigate, route, chrome }) {
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
chrome?.setComposer(null);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
@@ -148,6 +146,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(card, helpModal);
|
||||
screen.append(card);
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
modalRoot?.append(helpModal);
|
||||
screen.cleanup = () => {
|
||||
helpModal.remove();
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -58,7 +58,7 @@ function createSearchAvatar(login) {
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-search-screen';
|
||||
let searchTimer = 0;
|
||||
@@ -172,11 +172,11 @@ export function render({ navigate }) {
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Поиск контактов',
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}));
|
||||
screen.append(
|
||||
formCard,
|
||||
resultsCard,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, state } from '../state.js';
|
||||
import {
|
||||
isClientErrorReportingEnabled,
|
||||
@@ -246,16 +246,14 @@ function openUiErrorReportingModal() {
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки разработчика',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
|
||||
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить через камеру',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'camera-shell';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -165,7 +165,7 @@ function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
let savedKeys = null;
|
||||
@@ -178,12 +178,10 @@ export function render({ navigate }) {
|
||||
let dialogMode = '';
|
||||
let pendingTransferRequest = null;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить по коду',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const transferDialog = document.createElement('div');
|
||||
transferDialog.className = 'pairing-transfer-dialog';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import {
|
||||
@@ -9,16 +9,14 @@ import {
|
||||
|
||||
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать QR-код',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack qr-card';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
@@ -31,19 +31,17 @@ function formatOnlineStatus(onlineOnThisServer) {
|
||||
return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server';
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({navigate, route, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const sessionId = route?.params?.sessionId || '';
|
||||
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Сеанс устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
if (!session) {
|
||||
const empty = document.createElement('div');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
@@ -40,16 +40,14 @@ function sortSessionsByOnline(sessions = []) {
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
@@ -75,7 +73,7 @@ export function render({ navigate }) {
|
||||
|
||||
const createSessionItem = (session, isCurrent) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false };
|
||||
@@ -181,7 +181,7 @@ export function render() {
|
||||
})();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'DEVNET пополнение',
|
||||
}),
|
||||
senderBox,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authorizeLocalDemoSession,
|
||||
isLocalDemoAvailable,
|
||||
@@ -173,9 +173,9 @@ export function render({ navigate }) {
|
||||
actions.append(serverUiButton, cancelButton, saveButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Настройки входа',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
back: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
body,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authorizeSession, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
|
||||
@@ -91,9 +91,9 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Какие ключи сохранить',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
|
||||
back: { label: '←', onClick: () => navigate('login-password-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { saveEntryLanguage, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||
@@ -8,21 +8,19 @@ function resolveReturnPage() {
|
||||
return stored === 'start-view' ? 'start-view' : 'settings-view';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack language-screen';
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Язык / Language',
|
||||
leftAction: { label: '←', onClick: () => {
|
||||
back: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack language-choice-card';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
consumeAuthReturnPage,
|
||||
isAddingProfileLogin,
|
||||
clearAuthMessages,
|
||||
clearBrowserClientData,
|
||||
refreshSessions,
|
||||
@@ -205,10 +207,13 @@ export function render({ navigate }) {
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(session.login).catch(() => {});
|
||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||
@@ -221,7 +226,7 @@ export function render({ navigate }) {
|
||||
state.loginDraft.password = '';
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
||||
setAuthError(message);
|
||||
@@ -239,9 +244,9 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Войти по QR-коду',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
stopCamera();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
consumeAuthReturnPage,
|
||||
isAddingProfileLogin,
|
||||
clearAuthMessages,
|
||||
clearBrowserClientData,
|
||||
refreshSessions,
|
||||
@@ -182,10 +184,13 @@ export function render({ navigate }) {
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(session.login).catch(() => {});
|
||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||
@@ -199,7 +204,7 @@ export function render({ navigate }) {
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
||||
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
};
|
||||
|
||||
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
||||
@@ -215,10 +220,13 @@ export function render({ navigate }) {
|
||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||
};
|
||||
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(login).catch(() => {});
|
||||
await authService.persistSessionMaterial(login, sessionMaterial);
|
||||
const resumed = await authService.resumeSession(login, sessionId);
|
||||
authorizeSession({
|
||||
@@ -231,7 +239,7 @@ export function render({ navigate }) {
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
||||
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
};
|
||||
|
||||
const schedulePoll = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
@@ -113,7 +113,7 @@ export function render({ navigate }) {
|
||||
enterButton.disabled = true;
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||
|
||||
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
|
||||
const resolved = await authService.resolveLoginForAuth(currentLogin);
|
||||
@@ -170,9 +170,9 @@ export function render({ navigate }) {
|
||||
panel.append(title, passwordField, status, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
back: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
panel,
|
||||
overlay,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
cancelAddProfileLogin,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
@@ -153,9 +154,19 @@ export function render({ navigate }) {
|
||||
panel.append(title, loginField, status, remoteWrap, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
navigate('start-view');
|
||||
},
|
||||
},
|
||||
}),
|
||||
panel,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
@@ -200,34 +201,19 @@ export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const head = document.createElement('header');
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand">
|
||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||
</div>
|
||||
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div class="dm-head-menu" role="menu" hidden>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
head.querySelector('.dm-head-logo-wrap')?.append(
|
||||
createShineConnectionsLogo({ className: 'dm-head-logo' }),
|
||||
);
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'dm-head-brand';
|
||||
const logoWrap = document.createElement('span');
|
||||
logoWrap.className = 'dm-head-logo-wrap';
|
||||
logoWrap.setAttribute('aria-hidden', 'true');
|
||||
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
|
||||
brand.append(logoWrap);
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
||||
const filterTitle = document.createElement('button');
|
||||
filterTitle.type = 'button';
|
||||
filterTitle.className = 'dm-head-filter-title';
|
||||
filterTitle.textContent = 'Чаты';
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
@@ -237,8 +223,9 @@ export function render({ navigate, chrome }) {
|
||||
};
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: filterTitle,
|
||||
align: 'left',
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
@@ -250,91 +237,30 @@ export function render({ navigate, chrome }) {
|
||||
],
|
||||
});
|
||||
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||
// land on the content layer underneath. Render the open menu as a body portal.
|
||||
menuTemplate?.remove();
|
||||
|
||||
let menuPortal = null;
|
||||
|
||||
const closeHeadMenu = () => {
|
||||
menuPortal?.remove();
|
||||
menuPortal = null;
|
||||
menuButton?.setAttribute('aria-expanded', 'false');
|
||||
menuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionHeadMenu = () => {
|
||||
if (!menuPortal || !menuButton) return;
|
||||
const rect = menuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
const titleRect = filterTitle?.getBoundingClientRect?.();
|
||||
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
if (!menuButton || menuPortal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||
const searchIconHtml = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeHeadMenu();
|
||||
navigate('contact-search-view');
|
||||
const head = createTopBar({
|
||||
left: brand,
|
||||
center: filterTitle,
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
menuPortal = portal;
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
menuWrap?.classList.add('is-open');
|
||||
positionHeadMenu();
|
||||
};
|
||||
|
||||
menuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (menuPortal) closeHeadMenu();
|
||||
else openHeadMenu();
|
||||
});
|
||||
|
||||
const onOutsideClick = (event) => {
|
||||
if (!menuPortal) return;
|
||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !menuPortal) return;
|
||||
closeHeadMenu();
|
||||
menuButton?.focus();
|
||||
};
|
||||
const onMenuViewportChange = () => positionHeadMenu();
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onMenuKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack dm-list';
|
||||
@@ -364,11 +290,11 @@ function renderRow(item) {
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<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">
|
||||
${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>
|
||||
</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>
|
||||
`;
|
||||
const titleEl = row.querySelector('.dm-row-title');
|
||||
@@ -522,13 +448,7 @@ function renderRow(item) {
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
chatFilterMenu.destroy();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
@@ -9,7 +8,18 @@ import { engineModelFromGraphModel } from './network/adapter.js';
|
||||
import { openNodeMenu } from './network/node-menu.js';
|
||||
import { userDisplayName } from '../services/user-display.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
export const pageMeta = {
|
||||
id: 'network-view',
|
||||
title: 'Связи',
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
bottomFadeAnchor: 'toolbar',
|
||||
fadeProfile: 'edge',
|
||||
contentUnderTopbar: true,
|
||||
scrollContainer: 'locked',
|
||||
},
|
||||
};
|
||||
|
||||
const GENDER_MALE = 'male';
|
||||
const GENDER_FEMALE = 'female';
|
||||
@@ -191,6 +201,269 @@ function buildGraphModel(graph, centerLogin) {
|
||||
|
||||
let persistedCenterLogin = '';
|
||||
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 } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
@@ -198,12 +471,11 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
if (!keepHistory) {
|
||||
persistedCenterLogin = '';
|
||||
persistedCenterHistory = [];
|
||||
persistedGraphHistory = [];
|
||||
}
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'network-screen';
|
||||
const appScreenEl = document.getElementById('app-screen');
|
||||
appScreenEl?.classList.add('network-scroll-lock');
|
||||
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'network-stage';
|
||||
@@ -213,27 +485,36 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
|
||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
|
||||
let engine = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||
// Независимые фильтры карты. Оба выключены = показываем всё.
|
||||
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
|
||||
const FILTERS = {
|
||||
all: { label: 'Все', pred: () => true },
|
||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
|
||||
close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
|
||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
||||
};
|
||||
const FILTER_ORDER = ['all', 'friends', 'shining'];
|
||||
let activeFilter = 'all';
|
||||
const FILTER_ORDER = ['close', 'shining'];
|
||||
const activeFilters = new Set();
|
||||
const filterChips = {};
|
||||
|
||||
function currentFilterPredicate(node) {
|
||||
for (const key of activeFilters) {
|
||||
if (!FILTERS[key].pred(node)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFilter(key) {
|
||||
if (!FILTERS[key]) return;
|
||||
activeFilter = key;
|
||||
if (activeFilters.has(key)) activeFilters.delete(key);
|
||||
else activeFilters.add(key);
|
||||
FILTER_ORDER.forEach((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) {
|
||||
@@ -246,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
function persistHistory() {
|
||||
persistedCenterLogin = centerLogin;
|
||||
persistedCenterHistory = [...centerHistory];
|
||||
persistedGraphHistory = [...graphHistory];
|
||||
}
|
||||
|
||||
function syncLinksUrl(login, { push = false } = {}) {
|
||||
@@ -384,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
stage: board,
|
||||
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) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
@@ -407,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 prevCenter = centerLogin;
|
||||
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
||||
@@ -422,10 +707,35 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||
|
||||
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);
|
||||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
||||
|
||||
persistHistory();
|
||||
} catch (error) {
|
||||
@@ -434,44 +744,40 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
rightActions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню связей',
|
||||
ariaLabel: 'Открыть меню связей',
|
||||
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||
onClick: () => {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const networkMenuButton = header.querySelector('.network-header-menu-btn');
|
||||
const searchIconHtml = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
`;
|
||||
const networkMenu = createDropdownMenu({
|
||||
anchorEl: networkMenuButton,
|
||||
const header = createTopBar({
|
||||
title: 'Связи',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню связей',
|
||||
ariaLabel: 'Открыть меню связей',
|
||||
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
networkMenu.destroy();
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
if (routeLogin) {
|
||||
centerLogin = routeLogin;
|
||||
centerHistory = [];
|
||||
graphHistory = [];
|
||||
persistHistory();
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else if (keepHistory && centerLogin) {
|
||||
@@ -479,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
} else {
|
||||
centerLogin = normalizeLogin(state.session.login || '');
|
||||
centerHistory = [];
|
||||
graphHistory = [];
|
||||
persistHistory();
|
||||
if (centerLogin) {
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
@@ -496,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
FILTER_ORDER.forEach((key) => {
|
||||
const chip = document.createElement('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.addEventListener('click', () => applyFilter(key));
|
||||
filterChips[key] = chip;
|
||||
|
||||
@@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) {
|
||||
relationType: 'self',
|
||||
strength: 1,
|
||||
shining: Boolean(centerMark?.shine),
|
||||
official: Boolean(centerMark?.official),
|
||||
tier: 1,
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) {
|
||||
relationType: relationTypeFromRelation(r),
|
||||
strength: deriveStrength(r || {}),
|
||||
shining: Boolean(r?.mark?.shine),
|
||||
official: Boolean(r?.mark?.official),
|
||||
tier: 1,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами
|
||||
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||
const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты
|
||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||||
@@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const rebuildIndex = () => {
|
||||
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||
hasDeep = nodes.some((n) => n.tier >= 2);
|
||||
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
|
||||
childCountByParent = 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;
|
||||
for (const n of nodes) {
|
||||
if (n.tier >= 2 && n.parentId) {
|
||||
const i = childCountByParent.get(n.parentId) || 0;
|
||||
n.sibIndex = i;
|
||||
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) {
|
||||
tier1count += 1;
|
||||
}
|
||||
}
|
||||
degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня
|
||||
degreeById.set(focusId, tier1count);
|
||||
};
|
||||
|
||||
// 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 tier1Orbit = buildOrbitPlacements(tier1.length);
|
||||
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-й — маленькими аватарками
|
||||
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 };
|
||||
@@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
|
||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||
const tier = Number(src.tier) || 1;
|
||||
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
|
||||
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
|
||||
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
|
||||
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
|
||||
// Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view
|
||||
// может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему
|
||||
// кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер.
|
||||
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-й — микрозвезда.
|
||||
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 tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
||||
const ty = isFocus ? 0 : Math.sin(angle) * targetR;
|
||||
const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR);
|
||||
const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR);
|
||||
const el = buildNodeElement(src, isFocus, tier, dotOnly);
|
||||
world.append(el);
|
||||
return {
|
||||
...src,
|
||||
isFocus,
|
||||
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,
|
||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
|
||||
@@ -501,6 +530,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
||||
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
||||
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
|
||||
const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg';
|
||||
function buildPngOrb(src, opts) {
|
||||
const o = opts || {};
|
||||
const wrap = document.createElement('div');
|
||||
@@ -528,15 +558,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
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) {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
|
||||
if (dotOnly) {
|
||||
el.className = [
|
||||
'fg-node', 'fg-dot',
|
||||
'ui-button', 'fg-node', 'fg-dot',
|
||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
||||
src.shining ? 'is-shine' : '',
|
||||
src.official ? 'is-official' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
].filter(Boolean).join(' ');
|
||||
el.dataset.nodeId = String(src.id);
|
||||
@@ -546,9 +596,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return el;
|
||||
}
|
||||
el.className = [
|
||||
'fg-node',
|
||||
'ui-button', 'fg-node',
|
||||
isFocus ? 'is-focus' : '',
|
||||
src.shining ? 'is-shine' : '',
|
||||
src.official ? 'is-official' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
|
||||
tier >= 2 ? 'is-secondary' : '',
|
||||
@@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
|
||||
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
|
||||
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');
|
||||
label.className = 'fg-node-label';
|
||||
@@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return el;
|
||||
}
|
||||
|
||||
// Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex.
|
||||
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; }
|
||||
}
|
||||
}
|
||||
// Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой.
|
||||
function updateBadges() {}
|
||||
|
||||
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
|
||||
function updateA11y() {
|
||||
@@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.strength = strength;
|
||||
node.relationType = src.relationType;
|
||||
node.shining = Boolean(src.shining);
|
||||
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
|
||||
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
|
||||
node.official = Boolean(src.official);
|
||||
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.tx = Math.cos(node.angle) * node.targetR;
|
||||
node.ty = Math.sin(node.angle) * node.targetR;
|
||||
node.tx = node.fixedLayout ? layoutX : Math.cos(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.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||
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)));
|
||||
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||
node.el.className = spec.dotOnly
|
||||
? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||
: ['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', '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' : '', 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})`;
|
||||
}
|
||||
|
||||
|
||||
// Ограничение ручного 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) и, если он
|
||||
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
|
||||
@@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
zoom = z1;
|
||||
camX = sx - centerX - wx * z1;
|
||||
camY = sy - centerY - wy * z1;
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
@@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
n.lod = full ? 'full' : 'dot';
|
||||
n.dotOnly = !full;
|
||||
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() {
|
||||
if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) {
|
||||
renderHistoryEdges();
|
||||
return;
|
||||
}
|
||||
const focus = nodes.find((n) => n.id === focusId);
|
||||
if (!focus) {
|
||||
edgesSvg.innerHTML = '';
|
||||
@@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
|
||||
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
|
||||
// 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 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)}`;
|
||||
@@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let ax = 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);
|
||||
ay += K_FOCUS * (0 - n.y);
|
||||
@@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
||||
const dist = Math.sqrt(dist2);
|
||||
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
|
||||
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
|
||||
const ex = Math.max(n.expandP || 0, m.expandP || 0);
|
||||
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
|
||||
ax += (dx / dist) * f;
|
||||
@@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
|
||||
const visiblePeers = [];
|
||||
nodes.forEach((n) => {
|
||||
if (n.isFocus) { n.hidden = false; return; }
|
||||
if (n.isFocus || n.keepVisible) { n.hidden = false; return; }
|
||||
n.hidden = !pred(n);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
@@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
};
|
||||
|
||||
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) => {
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
apply(n, n.tx, n.ty, sc, 1);
|
||||
@@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// инерция панорамирования (kinematic): камера докатывается с трением
|
||||
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
||||
if (panActive) {
|
||||
camX += panVelX;
|
||||
camY += panVelY;
|
||||
const proposedX = camX + panVelX;
|
||||
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;
|
||||
panVelY *= PAN_FRICTION;
|
||||
applyWorldTransform();
|
||||
@@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const newCamY = camStartY + dy;
|
||||
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
|
||||
panVelY = newCamY - camY;
|
||||
camX = newCamX;
|
||||
camY = newCamY;
|
||||
const clamped = clampCameraPosition(newCamX, newCamY);
|
||||
camX = clamped.x;
|
||||
camY = clamped.y;
|
||||
if (camX !== newCamX) panVelX = 0;
|
||||
if (camY !== newCamY) panVelY = 0;
|
||||
applyWorldTransform();
|
||||
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
|
||||
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
||||
@@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
viewH = stage.clientHeight || window.innerHeight;
|
||||
centerX = viewW / 2;
|
||||
centerY = viewH / 2;
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
}
|
||||
|
||||
@@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||
freezeGraph();
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
return;
|
||||
}
|
||||
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));
|
||||
|
||||
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
||||
spawnGhost();
|
||||
// В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят
|
||||
// в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём.
|
||||
if (!nextModel?.preserveHistory) spawnGhost();
|
||||
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
||||
|
||||
focusId = String(newFocusId);
|
||||
@@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return node;
|
||||
});
|
||||
pendingFocusOrigin = null;
|
||||
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
|
||||
// Новый центр меняет положение карты, но пользовательский масштаб сохраняем.
|
||||
// Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком.
|
||||
diveTargetId = null; surfacing = false;
|
||||
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
|
||||
updateBadges(); // бейджи-счётчики связей под новый набор
|
||||
updateA11y(); // текстовый список графа для скринридеров
|
||||
|
||||
@@ -36,7 +36,7 @@ export function openNodeMenu({ login, displayName = '', relationType, point, act
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const itemsHtml = actions
|
||||
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
|
||||
.map((a, i) => `<button class="ui-button fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -320,7 +320,7 @@ function renderItem(item, activeTab, navigate) {
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
@@ -107,9 +107,9 @@ export function render({ navigate, chrome }) {
|
||||
screen.className = 'stack profile-screen';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Редактирование профиля',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -454,7 +454,7 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = values.map((value) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
|
||||
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
|
||||
)).join('');
|
||||
};
|
||||
|
||||
|
||||
+172
-137
@@ -1,12 +1,9 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
loadProfileSnapshot,
|
||||
} from '../services/user-profile-params.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { userDisplayName } from '../services/user-display.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -19,171 +16,212 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function fieldMap(snapshot) {
|
||||
const out = {};
|
||||
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
|
||||
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
|
||||
});
|
||||
return out;
|
||||
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
const numericValue = Number(value || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||
data-profile-list="${escapeHtml(kind)}"
|
||||
aria-label="${escapeHtml(label)}: ${numericValue}"
|
||||
>
|
||||
<span class="user-profile-metric-circle">${numericValue}</span>
|
||||
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function openTextModal(title, text) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="profile-text-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text || 'Не заполнено')}</div>
|
||||
<button class="secondary-btn" id="profile-text-close">Закрыть</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#profile-text-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-text-modal') close();
|
||||
});
|
||||
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 statusBadges(accountRole, shineStatus) {
|
||||
const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
|
||||
const shine = shineStatus === 'shining' ? 'Сияющий' : '';
|
||||
return `<div class="row wrap-row">
|
||||
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
|
||||
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
|
||||
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
|
||||
</div>`;
|
||||
function hasContacts(card) {
|
||||
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
|
||||
}
|
||||
|
||||
function statsRows(stats = {}) {
|
||||
return [
|
||||
['friends', 'Друзья', stats.friendsCount],
|
||||
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
|
||||
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
|
||||
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
|
||||
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
|
||||
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
|
||||
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
|
||||
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
|
||||
];
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
if (!rows.length) {
|
||||
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||
}
|
||||
return rows.map(([label, value]) => `
|
||||
<div class="user-profile-contact-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<b>${escapeHtml(value)}</b>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || profile.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profile-screen';
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
</div>`;
|
||||
const menuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: menuButton,
|
||||
const topbar = createTopBar({
|
||||
title: login || 'Профиль',
|
||||
className: 'topbar--profile user-profile-header',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню профиля',
|
||||
ariaLabel: 'Меню профиля',
|
||||
className: 'profile-head-menu-btn',
|
||||
menu: {
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
minWidth: 250,
|
||||
items: [
|
||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
|
||||
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
|
||||
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'status-line';
|
||||
status.className = 'status-line user-profile-status';
|
||||
status.textContent = 'Загрузка профиля...';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'stack';
|
||||
body.className = 'user-profile-body';
|
||||
screen.append(status, body);
|
||||
|
||||
let current = null;
|
||||
let card = null;
|
||||
|
||||
function renderProfile() {
|
||||
if (!current) return;
|
||||
const { snapshot, user } = current;
|
||||
const fields = fieldMap(snapshot);
|
||||
const firstName = fields.first_name || '';
|
||||
const lastName = fields.last_name || '';
|
||||
const displayName = userDisplayName({ login, firstName, lastName });
|
||||
const avatar = snapshot?.avatar?.txId
|
||||
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null;
|
||||
const stats = {
|
||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||
friendsCount: Number(user?.friendsCount || 0),
|
||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
|
||||
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
|
||||
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
|
||||
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
|
||||
};
|
||||
if (!card) return;
|
||||
const stats = card.stats || {};
|
||||
const official = card.accountRole === 'primary';
|
||||
const shining = card.shineStatus === 'shining';
|
||||
const fullName = [card.firstName, card.lastName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const displayName = fullName || card.login || login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
body.innerHTML = '';
|
||||
const identity = document.createElement('div');
|
||||
identity.className = 'card row';
|
||||
identity.style.gap = '12px';
|
||||
identity.style.alignItems = 'center';
|
||||
identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
|
||||
const identityText = document.createElement('div');
|
||||
identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
|
||||
identity.append(identityText);
|
||||
body.append(identity);
|
||||
const title = topbar.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login || login || 'Профиль';
|
||||
|
||||
const badges = document.createElement('div');
|
||||
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
|
||||
body.append(...badges.children);
|
||||
body.innerHTML = `
|
||||
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
|
||||
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
|
||||
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||
<div class="user-profile-avatar-slot"></div>
|
||||
</div>
|
||||
|
||||
if (fields.about) {
|
||||
const about = document.createElement('div');
|
||||
about.className = 'card profile-about';
|
||||
about.style.whiteSpace = 'pre-wrap';
|
||||
about.textContent = fields.about;
|
||||
body.append(about);
|
||||
}
|
||||
<div class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
|
||||
</div>
|
||||
|
||||
const statsGrid = document.createElement('div');
|
||||
statsGrid.className = 'profile-stats-grid';
|
||||
statsRows(stats).forEach(([kind, label, value]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'card profile-stat-card';
|
||||
button.dataset.profileList = kind;
|
||||
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
|
||||
statsGrid.append(button);
|
||||
});
|
||||
body.append(statsGrid);
|
||||
<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>
|
||||
|
||||
const detailRow = document.createElement('div');
|
||||
detailRow.className = 'row wrap-row';
|
||||
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
|
||||
body.append(detailRow);
|
||||
<section class="user-profile-about-card" aria-label="О себе">
|
||||
<div class="user-profile-about-title">О себе</div>
|
||||
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
|
||||
</section>
|
||||
|
||||
${(contactsVisible || spiritualPath) ? `
|
||||
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
|
||||
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
|
||||
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
|
||||
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-actions" aria-label="Действия со своим профилем">
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
avatarSlot?.append(renderUserAvatar({
|
||||
login: card.login,
|
||||
firstName: card.firstName,
|
||||
lastName: card.lastName,
|
||||
avatar: card.avatar,
|
||||
size: 'xl',
|
||||
className: 'user-profile-hero-avatar',
|
||||
glow: shining,
|
||||
}));
|
||||
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
body.addEventListener('click', (event) => {
|
||||
if (!current) return;
|
||||
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
|
||||
if (!el) return;
|
||||
const kind = el.dataset.profileList;
|
||||
if (kind) {
|
||||
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
|
||||
if (!card) return;
|
||||
|
||||
const listButton = event.target.closest('[data-profile-list]');
|
||||
if (listButton) {
|
||||
const kind = listButton.dataset.profileList;
|
||||
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
|
||||
return;
|
||||
}
|
||||
const fields = fieldMap(current.snapshot);
|
||||
if (el.dataset.profileDetail === 'contacts') {
|
||||
openTextModal('Контакты', [
|
||||
fields.web ? `Links: ${fields.web}` : '',
|
||||
fields.phone ? `Телефон: ${fields.phone}` : '',
|
||||
fields.address ? `Адрес: ${fields.address}` : '',
|
||||
].filter(Boolean).join('\n') || 'Не заполнено');
|
||||
|
||||
const actionButton = event.target.closest('[data-self-profile-action]');
|
||||
if (actionButton) {
|
||||
const action = actionButton.dataset.selfProfileAction;
|
||||
if (action === 'edit') navigate('profile-edit-view');
|
||||
if (action === 'wallet') navigate('wallet-view');
|
||||
if (action === 'settings') navigate('settings-view');
|
||||
return;
|
||||
}
|
||||
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
|
||||
|
||||
const detailButton = event.target.closest('[data-profile-detail]');
|
||||
if (!detailButton) return;
|
||||
const detailKind = detailButton.dataset.profileDetail;
|
||||
const detailPanel = body.querySelector('#profile-view-detail-panel');
|
||||
if (!detailPanel) return;
|
||||
|
||||
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
|
||||
const active = button === detailButton;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
|
||||
if (detailKind === 'spiritual-path') {
|
||||
detailPanel.innerHTML = spiritualPathDetailHtml(card);
|
||||
} else if (detailKind === 'contacts') {
|
||||
detailPanel.innerHTML = contactsDetailHtml(card);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
detailPanel.hidden = false;
|
||||
detailPanel.dataset.activeDetail = detailKind;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
@@ -191,17 +229,14 @@ export function render({ navigate, chrome }) {
|
||||
status.textContent = 'Локальный тестовый режим.';
|
||||
return;
|
||||
}
|
||||
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
|
||||
current = { snapshot, user };
|
||||
card = await loadUserProfileCard(login);
|
||||
renderProfile();
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
refresh().catch((error) => {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.className = 'status-line user-profile-status is-unavailable';
|
||||
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||
});
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
closeAllSavedProfiles,
|
||||
closeSavedProfile,
|
||||
getSavedProfiles,
|
||||
prepareAddProfileLogin,
|
||||
state,
|
||||
switchToSavedProfile,
|
||||
} from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'profiles-view', title: 'Профили' };
|
||||
|
||||
function reloadTo(path) {
|
||||
const clean = String(path || '/profile').trim() || '/profile';
|
||||
window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
|
||||
}
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profiles-screen';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Профили',
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}));
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'meta-muted profiles-summary';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack profiles-list';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'status-line';
|
||||
status.hidden = true;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack profiles-actions';
|
||||
|
||||
const addButton = document.createElement('button');
|
||||
addButton.type = 'button';
|
||||
addButton.className = 'secondary-btn';
|
||||
addButton.textContent = 'Добавить профиль';
|
||||
addButton.addEventListener('click', async () => {
|
||||
addButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Подготавливаем вход в новый профиль…';
|
||||
try {
|
||||
await prepareAddProfileLogin();
|
||||
navigate('login-view');
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось начать добавление профиля: ${error?.message || 'unknown'}`;
|
||||
addButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const closeAllButton = document.createElement('button');
|
||||
closeAllButton.type = 'button';
|
||||
closeAllButton.className = 'secondary-btn profiles-close-all';
|
||||
closeAllButton.textContent = 'Закрыть все профили';
|
||||
closeAllButton.addEventListener('click', async () => {
|
||||
const profiles = getSavedProfiles();
|
||||
if (!profiles.length) return;
|
||||
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
|
||||
if (!confirmed) return;
|
||||
closeAllButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = 'Закрываем профили…';
|
||||
try {
|
||||
await closeAllSavedProfiles();
|
||||
reloadTo('/start');
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось закрыть профили: ${error?.message || 'unknown'}`;
|
||||
closeAllButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(addButton, closeAllButton);
|
||||
screen.append(intro, list, status, actions);
|
||||
|
||||
const renderList = () => {
|
||||
const profiles = getSavedProfiles();
|
||||
const active = profiles.find((item) => item.isActive);
|
||||
intro.textContent = profiles.length
|
||||
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
|
||||
: 'На устройстве нет сохранённых профилей.';
|
||||
closeAllButton.disabled = profiles.length === 0;
|
||||
list.innerHTML = '';
|
||||
|
||||
profiles.forEach((profile) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `card profiles-row${profile.isActive ? ' is-active' : ''}`;
|
||||
|
||||
const select = document.createElement('button');
|
||||
select.type = 'button';
|
||||
select.className = 'profiles-select';
|
||||
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||
select.disabled = profile.isActive;
|
||||
select.addEventListener('click', async () => {
|
||||
if (profile.isActive) return;
|
||||
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
|
||||
if (!confirmed) return;
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
status.textContent = `Подключаем профиль ${profile.login}…`;
|
||||
try {
|
||||
await switchToSavedProfile(profile.login);
|
||||
reloadTo('/profile');
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось переключить профиль: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
});
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'profiles-close';
|
||||
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', async () => {
|
||||
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
|
||||
const message = profile.isActive
|
||||
? (others.length
|
||||
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
|
||||
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
|
||||
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
|
||||
if (!window.confirm(message)) return;
|
||||
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
status.textContent = `Закрываем профиль ${profile.login}…`;
|
||||
try {
|
||||
const result = await closeSavedProfile(profile.login);
|
||||
if (profile.isActive) {
|
||||
reloadTo(result.nextProfile ? '/profile' : '/start');
|
||||
return;
|
||||
}
|
||||
status.hidden = true;
|
||||
renderList();
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось закрыть профиль: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
});
|
||||
|
||||
row.append(select, close);
|
||||
list.append(row);
|
||||
});
|
||||
};
|
||||
|
||||
renderList();
|
||||
return screen;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { defaultSolanaCluster } from '../deploy-config.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||
@@ -262,7 +262,7 @@ function readTicketFromUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -404,11 +404,11 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Очередь билета',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('start-view') },
|
||||
}));
|
||||
screen.append(
|
||||
content,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
|
||||
|
||||
@@ -265,16 +265,14 @@ function buildRecommendations(diag) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Диагностика PWA / Push',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
|
||||
const statusCard = document.createElement('div');
|
||||
statusCard.className = 'card stack';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
@@ -424,9 +424,9 @@ export function render({ navigate }) {
|
||||
renderInputStage();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Зарегистрироваться',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
resetRegistrationFlow();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
@@ -178,9 +178,9 @@ export function render({ navigate }) {
|
||||
actions.append(backButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Сгенерированные ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
back: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
|
||||
@@ -215,9 +215,9 @@ export function render({ navigate }) {
|
||||
actions.append(backButton, registerButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Вопросы о регистрации',
|
||||
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||
back: { label: '←', onClick: () => navigate('register-view') },
|
||||
}),
|
||||
heroCard,
|
||||
topicCard,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
cancelAddProfileLogin,
|
||||
consumeAuthReturnPage,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
resetRegistrationFlow,
|
||||
@@ -103,7 +105,12 @@ export function render({ navigate }) {
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => {
|
||||
cancelButton.addEventListener('click', async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
});
|
||||
@@ -143,7 +150,7 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||
}
|
||||
|
||||
await clearStoredMessages().catch(() => {});
|
||||
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||
|
||||
authorizeSession({
|
||||
login: state.registrationDraft.login,
|
||||
@@ -174,13 +181,7 @@ export function render({ navigate }) {
|
||||
setAuthInfo(isLoginFlow
|
||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||
const nextHash = String(state.authReturnHash || '').trim();
|
||||
state.authReturnHash = '';
|
||||
if (nextHash.startsWith('/')) {
|
||||
navigate(nextHash.slice(1));
|
||||
} else {
|
||||
navigate('profile-view');
|
||||
}
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||
setAuthError(message);
|
||||
@@ -192,11 +193,16 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Сохранение ключей',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
|
||||
},
|
||||
);
|
||||
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
|
||||
await clearStoredMessages().catch(() => {});
|
||||
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||
|
||||
const resumed = await authService.resumeSession(result.login, result.sessionId);
|
||||
const resumedLogin = resumed.login || result.login;
|
||||
@@ -374,9 +374,9 @@ export function render({ navigate }) {
|
||||
card.append(showKeysButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Оплата регистрации',
|
||||
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||
back: { label: '←', onClick: () => navigate('register-view') },
|
||||
}),
|
||||
card,
|
||||
);
|
||||
@@ -398,7 +398,7 @@ export function render({ navigate }) {
|
||||
function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
|
||||
const screen = document.querySelector('section.stack');
|
||||
if (!screen) return;
|
||||
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
|
||||
const headerBackButton = screen.querySelector('.topbar .topbar__back');
|
||||
const card = screen.querySelector('.card.stack');
|
||||
if (!card) return;
|
||||
card.classList.add('registration-finish-card');
|
||||
@@ -585,7 +585,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
||||
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
|
||||
const screen = document.querySelector('section.stack');
|
||||
if (!screen) return;
|
||||
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
|
||||
const headerBackButton = screen.querySelector('.topbar .topbar__back');
|
||||
const card = screen.querySelector('.card.stack');
|
||||
if (!card) return;
|
||||
card.classList.add('registration-finish-card');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
@@ -29,16 +29,14 @@ function sessionLabel(session) {
|
||||
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'AddBlock через homeserver',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
@@ -69,7 +67,7 @@ export function render({ navigate }) {
|
||||
sessions.forEach((session) => {
|
||||
const sessionId = String(session?.sessionId || '').trim();
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const isSelected = sessionId && sessionId === selectedId;
|
||||
item.innerHTML = `
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { saveEntrySettings, state } from '../state.js';
|
||||
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
|
||||
|
||||
@@ -10,7 +10,7 @@ const SERVER_FIELDS = [
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -185,7 +185,7 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, saveButton);
|
||||
|
||||
const help = document.createElement('button');
|
||||
help.className = 'help-fab';
|
||||
help.className = 'ui-button help-fab';
|
||||
help.type = 'button';
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
@@ -194,11 +194,11 @@ export function render({ navigate }) {
|
||||
);
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Серверы блокчейнов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
introCard,
|
||||
body,
|
||||
actions,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -25,17 +25,15 @@ function formatVersionForUi(rawValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
let isDisposed = false;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
@@ -77,7 +75,7 @@ export function render({ navigate }) {
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
|
||||
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -88,9 +86,8 @@ export function render({ navigate }) {
|
||||
source: 'session',
|
||||
message: 'Запрошено завершение текущей сессии',
|
||||
});
|
||||
await closeCurrentSessionAndSignOut({
|
||||
infoMessage: 'Сеанс завершён. Выполните вход заново.',
|
||||
});
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||
} finally {
|
||||
signOutBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
@@ -6,7 +6,7 @@ import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -22,12 +22,10 @@ export function render({ navigate }) {
|
||||
device: '',
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
|
||||
export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
|
||||
|
||||
@@ -132,7 +132,7 @@ function makeResultCard(endpoint) {
|
||||
return { card, badge, statusLine, details };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -242,11 +242,11 @@ export function render({ navigate }) {
|
||||
});
|
||||
resetBtn.addEventListener('click', resetState);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Проверка Solana RPC',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
intro,
|
||||
summary,
|
||||
grid,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
SHINE_USERS_ECONOMY_CONFIG_SEED,
|
||||
SHINE_USERS_PROGRAM_ID,
|
||||
@@ -29,7 +29,7 @@ function shortAddr(value = '') {
|
||||
return `${v.slice(0, 6)}...${v.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -184,11 +184,11 @@ export function render({ navigate }) {
|
||||
status,
|
||||
);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Solana Init (users)',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
card,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
createSolanaWalletFromPrivateBase58,
|
||||
@@ -161,9 +161,9 @@ export function render({ navigate }) {
|
||||
})();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Пополнение solana счета',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
back: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
status,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, setAuthError, setAuthInfo, state } from '../state.js';
|
||||
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -18,7 +18,7 @@ function describeState(settings) {
|
||||
return 'Вход через другое устройство разрешён без дополнительного пароля.';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -182,11 +182,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки входа через устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}));
|
||||
screen.append(
|
||||
card,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
@@ -13,28 +13,93 @@ function parseAvatar(raw) {
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт',
|
||||
friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт',
|
||||
primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
|
||||
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
|
||||
};
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const login = String(route?.params?.login || '').trim();
|
||||
const kind = String(route?.params?.kind || '').trim();
|
||||
const screen = document.createElement('section'); screen.className = 'stack';
|
||||
const body = document.createElement('div'); body.className = 'stack';
|
||||
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
|
||||
screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
|
||||
function friendTabsHtml(activeKind) {
|
||||
return `
|
||||
<div class="profile-list-tabs" role="tablist" aria-label="Тип друзей">
|
||||
<button type="button" class="profile-list-tab${activeKind === 'friends' ? ' is-active' : ''}" data-friend-kind="friends" role="tab" aria-selected="${activeKind === 'friends'}">Друзья</button>
|
||||
<button type="button" class="profile-list-tab${activeKind === 'close_friends' ? ' is-active' : ''}" data-friend-kind="close_friends" role="tab" aria-selected="${activeKind === 'close_friends'}">Близкие друзья</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const login = String(route?.params?.login || '').trim();
|
||||
const initialKind = String(route?.params?.kind || '').trim();
|
||||
let activeKind = initialKind;
|
||||
const isFriendsScreen = initialKind === 'friends' || initialKind === 'close_friends';
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'stack';
|
||||
const status = document.createElement('div');
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Загрузка...';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: TITLES[initialKind] || 'Список',
|
||||
back: { label: '←', onClick: () => navigateBack() },
|
||||
}));
|
||||
|
||||
if (isFriendsScreen) {
|
||||
const tabs = document.createElement('div');
|
||||
tabs.innerHTML = friendTabsHtml(activeKind);
|
||||
screen.append(tabs.firstElementChild);
|
||||
}
|
||||
screen.append(status, body);
|
||||
|
||||
let loadGeneration = 0;
|
||||
|
||||
function renderRelationRows(rows) {
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({
|
||||
login: row.login,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
avatar: parseAvatar(row.avatarAr),
|
||||
size: 'md',
|
||||
}));
|
||||
const fullName = userDisplayName(row);
|
||||
const t = document.createElement('div');
|
||||
t.className = 'profile-list-row-text';
|
||||
const marks = [
|
||||
row.relationType && row.relationType !== 'none' ? ({ contact: 'контакт', friend: 'друг', close_friend: 'близкий друг' }[row.relationType] || row.relationType) : '',
|
||||
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
|
||||
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
|
||||
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
|
||||
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
t.innerHTML = `<b>${fullName}</b><small>@${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
|
||||
el.append(t);
|
||||
el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`));
|
||||
body.append(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(kind) {
|
||||
const generation = ++loadGeneration;
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Загрузка...';
|
||||
body.replaceChildren();
|
||||
try {
|
||||
if (kind === 'channels_owned' || kind === 'channels_following') {
|
||||
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
|
||||
if (generation !== loadGeneration) return;
|
||||
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
const t = document.createElement('div');
|
||||
t.className = 'profile-list-row-text';
|
||||
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
|
||||
el.append(t);
|
||||
el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`));
|
||||
@@ -43,25 +108,35 @@ export function render({ navigate, route }) {
|
||||
status.textContent = rows.length ? '' : 'Список пуст.';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
|
||||
if (generation !== loadGeneration) return;
|
||||
const rows = Array.isArray(payload?.users) ? payload.users : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const fullName = userDisplayName(row);
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
const marks = [
|
||||
row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '',
|
||||
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
|
||||
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
|
||||
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
|
||||
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
t.innerHTML = `<b>${fullName}</b><small>${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
|
||||
el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
|
||||
});
|
||||
renderRelationRows(rows);
|
||||
status.textContent = rows.length ? '' : 'Список пуст.';
|
||||
} catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; }
|
||||
})();
|
||||
} catch (e) {
|
||||
if (generation !== loadGeneration) return;
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Ошибка: ${e.message || 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFriendsScreen) {
|
||||
screen.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest('[data-friend-kind]');
|
||||
if (!tab) return;
|
||||
const nextKind = String(tab.dataset.friendKind || '');
|
||||
if (!nextKind || nextKind === activeKind) return;
|
||||
activeKind = nextKind;
|
||||
screen.querySelectorAll('[data-friend-kind]').forEach((button) => {
|
||||
const selected = button.dataset.friendKind === activeKind;
|
||||
button.classList.toggle('is-active', selected);
|
||||
button.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
void load(activeKind);
|
||||
});
|
||||
}
|
||||
|
||||
void load(activeKind);
|
||||
return screen;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user