SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
3ae9d9175f | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
d3e6aa2be2 | ||
|
|
19362b950a | ||
|
|
23bdadab56 | ||
|
|
47a4844ce8 | ||
|
|
8741be6cba | ||
|
|
4656a03ea9 | ||
|
|
5763eb828e | ||
|
|
6a5c20a165 | ||
|
|
fac166f186 | ||
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 |
@@ -98,14 +98,9 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@ import java.util.Objects;
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|
||||
+4
-1
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
||||
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
@@ -31,4 +34,4 @@ public final class ShineSignatureConstants {
|
||||
|
||||
/** Длина публичного ключа Ed25519. */
|
||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,22 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_2 = 2;
|
||||
public static final int SCHEMA_VERSION_3 = 3;
|
||||
public static final int SCHEMA_VERSION_4 = 4;
|
||||
public static final int SCHEMA_VERSION_5 = 5;
|
||||
public static final int SCHEMA_VERSION_6 = 6;
|
||||
public static final int SCHEMA_VERSION_7 = 7;
|
||||
public static final int SCHEMA_VERSION_8 = 8;
|
||||
public static final int SCHEMA_VERSION_9 = 9;
|
||||
public static final int SCHEMA_VERSION_10 = 10;
|
||||
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";
|
||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
|
||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
|
||||
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -43,10 +55,8 @@ public final class DatabaseInitializer {
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
@@ -100,6 +110,30 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_4) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_4;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_5) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_5;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_6) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_6;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_7) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_7;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_8) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_8;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_9) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_9;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_10) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,23 +58,17 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+6
-6
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
SET likes_count = GREATEST(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
likes_count - COALESCE((
|
||||
SELECT COUNT(*)::int
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
SET replies_count = GREATEST(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)::int
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
|
||||
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) LIKE ?
|
||||
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||
AND (? IS NULL OR is_server = ?)
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
|
||||
List<CurrentUserEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
ps.setString(1, prefix.trim() + "%");
|
||||
if (isServer == null) {
|
||||
ps.setNull(2, Types.BOOLEAN);
|
||||
ps.setNull(3, Types.BOOLEAN);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class DirectMessagesDAO {
|
||||
private static volatile DirectMessagesDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DirectMessagesDAO() {}
|
||||
|
||||
public static DirectMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DirectMessagesDAO.class) {
|
||||
if (instance == null) instance = new DirectMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(DirectMessageEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO direct_messages (
|
||||
message_id, from_login, to_login, text, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageId());
|
||||
ps.setString(2, entry.getFromLogin());
|
||||
ps.setString(3, entry.getToLogin());
|
||||
ps.setString(4, entry.getText());
|
||||
ps.setLong(5, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
return ps.executeQuery().next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDirectMessagesHistoryDAO {
|
||||
private static volatile SignedDirectMessagesHistoryDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SignedDirectMessagesHistoryDAO() {}
|
||||
|
||||
public static SignedDirectMessagesHistoryDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDirectMessagesHistoryDAO.class) {
|
||||
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_direct_messages_history (
|
||||
message_id, from_login, to_login, target_mode, target_session_id,
|
||||
message_type, time_ms, nonce, raw_packet, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageId());
|
||||
ps.setString(2, e.getFromLogin());
|
||||
ps.setString(3, e.getToLogin());
|
||||
ps.setInt(4, e.getTargetMode());
|
||||
ps.setString(5, e.getTargetSessionId());
|
||||
ps.setInt(6, e.getMessageType());
|
||||
ps.setLong(7, e.getTimeMs());
|
||||
ps.setLong(8, e.getNonce());
|
||||
ps.setBytes(9, e.getRawPacket());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDmReplayDAO {
|
||||
private static volatile SignedDmReplayDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private SignedDmReplayDAO() {}
|
||||
|
||||
public static SignedDmReplayDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDmReplayDAO.class) {
|
||||
if (instance == null) instance = new SignedDmReplayDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
|
||||
cleanupExpired(nowMs - 15L * 60L * 1000L);
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_direct_message_replay (
|
||||
from_login, time_ms, nonce, created_at_ms
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setLong(2, timeMs);
|
||||
ps.setLong(3, nonce);
|
||||
ps.setLong(4, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupExpired(long minCreatedAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, minCreatedAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserNotificationsStateDAO {
|
||||
private static volatile UserNotificationsStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserNotificationsStateDAO() {}
|
||||
|
||||
public static UserNotificationsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserNotificationsStateDAO.class) {
|
||||
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_notifications_state (
|
||||
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||
created_at_ms = EXCLUDED.created_at_ms,
|
||||
source_login = EXCLUDED.source_login,
|
||||
target_login = EXCLUDED.target_login,
|
||||
target_bch_name = EXCLUDED.target_bch_name,
|
||||
target_block_number = EXCLUDED.target_block_number,
|
||||
target_block_hash = EXCLUDED.target_block_hash,
|
||||
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||
source_text = EXCLUDED.source_text
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, e.getOwnerLogin());
|
||||
ps.setString(i++, e.getNotificationKind());
|
||||
ps.setLong(i++, e.getCreatedAtMs());
|
||||
ps.setString(i++, e.getSourceLogin());
|
||||
ps.setString(i++, e.getSourceBchName());
|
||||
ps.setInt(i++, e.getSourceBlockNumber());
|
||||
ps.setBytes(i++, e.getSourceBlockHash());
|
||||
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||
ps.setInt(i++, e.getSourceMsgSubType());
|
||||
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, kind);
|
||||
ps.setInt(3, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setNotificationKind(rs.getString("notification_kind"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceLogin(rs.getString("source_login"));
|
||||
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||
e.setSourceText(rs.getString("source_text"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserSettingsDAO — хранение пользовательских настроек.
|
||||
*
|
||||
* Правило:
|
||||
* - уникальность: login + setting_type + setting_key
|
||||
* - запись обновляется только если time_ms новее
|
||||
* - synced=true означает, что значение уже дошло до второго сервера
|
||||
*/
|
||||
public final class UserSettingsDAO {
|
||||
|
||||
private static volatile UserSettingsDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsDAO() {}
|
||||
|
||||
public static UserSettingsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_settings (
|
||||
login, setting_type, setting_key, time_ms,
|
||||
value_text, value_num, client_key, signature, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (login, setting_type, setting_key)
|
||||
DO UPDATE SET
|
||||
time_ms = EXCLUDED.time_ms,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_num = EXCLUDED.value_num,
|
||||
client_key = EXCLUDED.client_key,
|
||||
signature = EXCLUDED.signature,
|
||||
synced = EXCLUDED.synced
|
||||
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setInt(2, e.getSettingType());
|
||||
ps.setString(3, e.getSettingKey());
|
||||
ps.setLong(4, e.getTimeMs());
|
||||
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
||||
ps.setLong(6, e.getValueNum());
|
||||
|
||||
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
||||
else ps.setString(7, e.getClientKey());
|
||||
|
||||
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
||||
else ps.setString(8, e.getSignature());
|
||||
|
||||
ps.setBoolean(9, e.isSynced());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return upsertIfNewer(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLoginTypeKey(c, login, settingType, settingKey);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND (
|
||||
time_ms > ?
|
||||
OR (time_ms = ? AND setting_key > ?)
|
||||
)
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setLong(2, Math.max(0L, afterTimeMs));
|
||||
ps.setLong(3, Math.max(0L, afterTimeMs));
|
||||
ps.setString(4, afterSettingKey == null ? "" : afterSettingKey);
|
||||
ps.setInt(5, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND synced = FALSE
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = TRUE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = FALSE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced() throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return markAllUnsynced(c);
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingEntry e = new UserSettingEntry();
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSettingType(rs.getInt("setting_type"));
|
||||
e.setSettingKey(rs.getString("setting_key"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setValueText(rs.getString("value_text"));
|
||||
e.setValueNum(rs.getLong("value_num"));
|
||||
|
||||
String clientKey = rs.getString("client_key");
|
||||
if (rs.wasNull()) clientKey = null;
|
||||
e.setClientKey(clientKey);
|
||||
|
||||
String signature = rs.getString("signature");
|
||||
if (rs.wasNull()) signature = null;
|
||||
e.setSignature(signature);
|
||||
|
||||
e.setSynced(rs.getBoolean("synced"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingsSyncPeerStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class UserSettingsSyncPeerStateDAO {
|
||||
|
||||
private static volatile UserSettingsSyncPeerStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsSyncPeerStateDAO() {}
|
||||
|
||||
public static UserSettingsSyncPeerStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsSyncPeerStateDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
|
||||
if (existing != null) return existing;
|
||||
long nowMs = System.currentTimeMillis();
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return get(c, ownerLogin, remoteServerLogin);
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
|
||||
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
|
||||
FROM user_settings_sync_peer_state
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
cursor_time_ms = EXCLUDED.cursor_time_ms,
|
||||
cursor_setting_key = EXCLUDED.cursor_setting_key,
|
||||
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||
last_error = NULL,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, Math.max(0L, cursorTimeMs));
|
||||
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
|
||||
ps.setBoolean(6, bootstrapCompleted);
|
||||
ps.setLong(7, nowMs);
|
||||
ps.setLong(8, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setString(4, error);
|
||||
ps.setLong(5, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
|
||||
}
|
||||
|
||||
public int deleteAllForOwner(String ownerLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
|
||||
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
|
||||
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
|
||||
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DirectMessageEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedDirectMessageHistoryEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int targetMode;
|
||||
private String targetSessionId;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private byte[] rawPacket;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getTargetMode() { return targetMode; }
|
||||
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
|
||||
public String getTargetSessionId() { return targetSessionId; }
|
||||
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public byte[] getRawPacket() { return rawPacket; }
|
||||
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserNotificationEntry {
|
||||
private String ownerLogin;
|
||||
private String notificationKind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBchName;
|
||||
private int sourceBlockNumber;
|
||||
private byte[] sourceBlockHash;
|
||||
private String targetLogin;
|
||||
private String targetBchName;
|
||||
private Integer targetBlockNumber;
|
||||
private byte[] targetBlockHash;
|
||||
private int sourceMsgSubType;
|
||||
private String sourceText;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getNotificationKind() { return notificationKind; }
|
||||
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBchName() { return sourceBchName; }
|
||||
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBchName() { return targetBchName; }
|
||||
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* UserSettingEntry — одна пользовательская настройка.
|
||||
*
|
||||
* Таблица: user_settings
|
||||
* - login TEXT NOT NULL
|
||||
* - setting_type INTEGER NOT NULL
|
||||
* - setting_key TEXT NOT NULL
|
||||
* - time_ms BIGINT NOT NULL
|
||||
* - value_text TEXT NOT NULL
|
||||
* - value_num BIGINT NOT NULL
|
||||
* - client_key TEXT NOT NULL
|
||||
* - signature TEXT NOT NULL
|
||||
* - synced BOOLEAN NOT NULL
|
||||
*/
|
||||
public class UserSettingEntry {
|
||||
private String login;
|
||||
private int settingType;
|
||||
private String settingKey;
|
||||
private long timeMs;
|
||||
private String valueText;
|
||||
private long valueNum;
|
||||
private String clientKey;
|
||||
private String signature;
|
||||
private boolean synced;
|
||||
|
||||
public UserSettingEntry() {}
|
||||
|
||||
public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) {
|
||||
this.login = login;
|
||||
this.settingType = settingType;
|
||||
this.settingKey = settingKey;
|
||||
this.timeMs = timeMs;
|
||||
this.valueText = valueText;
|
||||
this.valueNum = valueNum;
|
||||
this.clientKey = clientKey;
|
||||
this.signature = signature;
|
||||
this.synced = synced;
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public int getSettingType() { return settingType; }
|
||||
public void setSettingType(int settingType) { this.settingType = settingType; }
|
||||
|
||||
public String getSettingKey() { return settingKey; }
|
||||
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
||||
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
|
||||
public String getValueText() { return valueText; }
|
||||
public void setValueText(String valueText) { this.valueText = valueText; }
|
||||
|
||||
public long getValueNum() { return valueNum; }
|
||||
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
||||
|
||||
public String getClientKey() { return clientKey; }
|
||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
|
||||
public boolean isSynced() { return synced; }
|
||||
public void setSynced(boolean synced) { this.synced = synced; }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserSettingsSyncPeerStateEntry {
|
||||
private String ownerLogin;
|
||||
private String remoteServerLogin;
|
||||
private String remoteServerUrl;
|
||||
private long cursorTimeMs;
|
||||
private String cursorSettingKey;
|
||||
private boolean bootstrapCompleted;
|
||||
private Long lastSyncAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
|
||||
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||
|
||||
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||
|
||||
public long getCursorTimeMs() { return cursorTimeMs; }
|
||||
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
|
||||
|
||||
public String getCursorSettingKey() { return cursorSettingKey; }
|
||||
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
|
||||
|
||||
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||
|
||||
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
|
||||
(
|
||||
SELECT
|
||||
current_users.login AS login,
|
||||
current_users.normalized_login AS normalized_login,
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||
source_block_hash BYTEA NOT NULL,
|
||||
target_login TEXT,
|
||||
target_bch_name TEXT,
|
||||
target_block_number INTEGER,
|
||||
target_block_hash BYTEA,
|
||||
source_msg_sub_type INTEGER NOT NULL,
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 10, 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;
|
||||
@@ -0,0 +1,83 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
|
||||
|
||||
UPDATE solana_user_pda_current
|
||||
SET normalized_login = LOWER(BTRIM(login))
|
||||
WHERE normalized_login IS NULL
|
||||
OR normalized_login <> LOWER(BTRIM(login));
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ALTER COLUMN normalized_login SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
WITH bad_signed_messages AS (
|
||||
SELECT DISTINCT sm.message_key
|
||||
FROM signed_messages sm
|
||||
LEFT JOIN solana_user_pda_current u_from
|
||||
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||
LEFT JOIN solana_user_pda_current u_to
|
||||
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||
)
|
||||
DELETE FROM signed_message_session_delivery d
|
||||
USING bad_signed_messages bad
|
||||
WHERE d.message_key = bad.message_key;
|
||||
|
||||
WITH bad_signed_messages AS (
|
||||
SELECT DISTINCT sm.message_key
|
||||
FROM signed_messages sm
|
||||
LEFT JOIN solana_user_pda_current u_from
|
||||
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||
LEFT JOIN solana_user_pda_current u_to
|
||||
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||
)
|
||||
DELETE FROM signed_messages sm
|
||||
USING bad_signed_messages bad
|
||||
WHERE sm.message_key = bad.message_key;
|
||||
|
||||
DELETE FROM signed_direct_messages_history h
|
||||
USING solana_user_pda_current u_from,
|
||||
solana_user_pda_current u_to
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
|
||||
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
|
||||
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
|
||||
|
||||
DELETE FROM signed_direct_message_replay r
|
||||
USING solana_user_pda_current u_from
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
|
||||
AND r.from_login <> u_from.login;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'direct_messages'
|
||||
) THEN
|
||||
DELETE FROM direct_messages d
|
||||
USING solana_user_pda_current u_from,
|
||||
solana_user_pda_current u_to
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
|
||||
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
|
||||
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 5, 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;
|
||||
@@ -0,0 +1,21 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE signed_messages
|
||||
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
|
||||
|
||||
ALTER TABLE signed_messages
|
||||
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
|
||||
|
||||
ALTER TABLE signed_direct_messages_history
|
||||
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
|
||||
|
||||
ALTER TABLE signed_direct_messages_history
|
||||
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 6, 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;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE blocks
|
||||
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
|
||||
|
||||
ALTER TABLE blocks
|
||||
ADD CONSTRAINT blocks_login_fkey
|
||||
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 7, 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;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE connections_state
|
||||
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
|
||||
|
||||
ALTER TABLE connections_state
|
||||
ADD CONSTRAINT connections_state_login_fkey
|
||||
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 8, 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;
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
setting_type INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
value_text TEXT NOT NULL DEFAULT '',
|
||||
value_num BIGINT NOT NULL DEFAULT 0,
|
||||
client_key TEXT NOT NULL DEFAULT '',
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (login, setting_type, setting_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
ON user_settings(login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
|
||||
ON user_settings(login, synced, time_ms);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
remote_server_url TEXT NOT NULL,
|
||||
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
cursor_setting_key TEXT NOT NULL DEFAULT '',
|
||||
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_sync_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, remote_server_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
|
||||
ON user_settings_sync_peer_state(owner_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 9, 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;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 9, 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;
|
||||
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
pda_address TEXT PRIMARY KEY,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
normalized_login TEXT NOT NULL,
|
||||
record_number INTEGER NOT NULL,
|
||||
slot BIGINT NOT NULL,
|
||||
last_tx_signature TEXT NOT NULL,
|
||||
@@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
|
||||
ON solana_user_pda_current(slot);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_access_servers_current (
|
||||
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
@@ -394,6 +401,44 @@ CREATE TABLE IF NOT EXISTS users_params (
|
||||
CREATE INDEX IF NOT EXISTS idx_users_params_login
|
||||
ON users_params(login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
setting_type INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
value_text TEXT NOT NULL DEFAULT '',
|
||||
value_num BIGINT NOT NULL DEFAULT 0,
|
||||
client_key TEXT NOT NULL DEFAULT '',
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (login, setting_type, setting_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
ON user_settings(login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
|
||||
ON user_settings(login, synced, time_ms);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
remote_server_url TEXT NOT NULL,
|
||||
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
cursor_setting_key TEXT NOT NULL DEFAULT '',
|
||||
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_sync_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, remote_server_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
|
||||
ON user_settings_sync_peer_state(owner_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -440,7 +485,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state(updated_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name),
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
msg_type INTEGER NOT NULL,
|
||||
@@ -470,7 +515,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
@@ -624,8 +669,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
target_mode INTEGER NOT NULL,
|
||||
target_session_id TEXT,
|
||||
message_type INTEGER NOT NULL,
|
||||
@@ -642,8 +687,8 @@ CREATE TABLE IF NOT EXISTS signed_messages (
|
||||
message_key TEXT PRIMARY KEY,
|
||||
base_key TEXT NOT NULL,
|
||||
target_login TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
nonce BIGINT NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
|
||||
+28
-2
@@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
|
||||
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
@@ -82,19 +88,22 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_MarkAllUserSettingsUnsynced_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UserSettingsSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
@@ -103,11 +112,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
@@ -182,6 +192,11 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
|
||||
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
|
||||
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
|
||||
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
@@ -194,6 +209,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -204,6 +220,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||
Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()),
|
||||
Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()),
|
||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
@@ -264,6 +282,11 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
|
||||
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
|
||||
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
|
||||
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
@@ -276,6 +299,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
@@ -286,6 +310,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
|
||||
Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class),
|
||||
Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class),
|
||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
|
||||
+69
-2
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
import org.slf4j.Logger;
|
||||
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(
|
||||
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return b;
|
||||
}
|
||||
|
||||
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
|
||||
if (block == null || storedEntry == null) return null;
|
||||
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
long createdAtMs = block.timestamp * 1000L;
|
||||
String ownerLogin = storedEntry.getToLogin();
|
||||
String sourceLogin = storedEntry.getLogin();
|
||||
|
||||
// A user must never receive a notification for replying to their own message/thread.
|
||||
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
|
||||
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
&& block.body instanceof TextReplyBody replyBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("reply");
|
||||
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
|
||||
String ownerLogin,
|
||||
long createdAtMs,
|
||||
int msgSubType) {
|
||||
UserNotificationEntry entry = new UserNotificationEntry();
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setCreatedAtMs(createdAtMs);
|
||||
entry.setSourceLogin(storedEntry.getLogin());
|
||||
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
|
||||
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
|
||||
entry.setSourceBlockHash(storedEntry.getBlockHash());
|
||||
entry.setTargetLogin(storedEntry.getToLogin());
|
||||
entry.setTargetBchName(storedEntry.getToBchName());
|
||||
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
|
||||
entry.setTargetBlockHash(storedEntry.getToBlockHash());
|
||||
entry.setSourceMsgSubType(msgSubType);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String blockchainNameFromEntry(BlockEntry entry) {
|
||||
return entry == null ? null : entry.getBchName();
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
+14
-2
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
ChannelNameStateDAO channelNameStateDAO,
|
||||
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
|
||||
+34
@@ -144,6 +144,40 @@ 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);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(0);
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
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.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
public static class NotificationItem {
|
||||
private String kind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBlockchainName;
|
||||
private int sourceBlockNumber;
|
||||
private String sourceBlockHash;
|
||||
private Integer sourceMsgSubType;
|
||||
private Integer connectionTypeCode;
|
||||
private String sourceText;
|
||||
private String targetLogin;
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public String getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public Integer getConnectionTypeCode() { return connectionTypeCode; }
|
||||
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings;
|
||||
|
||||
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.userSettings.entyties.Net_GetUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserSetting_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserSetting_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserSetting_Request req = (Net_GetUserSetting_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getSetting_type() == null
|
||||
|| req.getSetting_key() == null || req.getSetting_key().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login/setting_type/setting_key");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String login = CurrentUsersDAO.getInstance().getByLogin(c, req.getLogin().trim()) != null
|
||||
? req.getLogin().trim()
|
||||
: null;
|
||||
if (login == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
UserSettingEntry entry = UserSettingsDAO.getInstance().getByLoginTypeKey(c, login, req.getSetting_type(), req.getSetting_key().trim());
|
||||
if (entry == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "SETTING_NOT_FOUND", "Настройка не найдена");
|
||||
}
|
||||
|
||||
Net_GetUserSetting_Response resp = new Net_GetUserSetting_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(entry.getLogin());
|
||||
resp.setSetting_type(entry.getSettingType());
|
||||
resp.setSetting_key(entry.getSettingKey());
|
||||
resp.setTime_ms(entry.getTimeMs());
|
||||
resp.setValue_text(entry.getValueText());
|
||||
resp.setValue_num(entry.getValueNum());
|
||||
resp.setClient_key(entry.getClientKey());
|
||||
resp.setSignature(entry.getSignature());
|
||||
resp.setSynced(entry.isSynced());
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserSetting failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings;
|
||||
|
||||
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.userSettings.entyties.Net_ListUserSettings_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListUserSettings_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_ListUserSettings_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_ListUserSettings_Request req = (Net_ListUserSettings_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String login = req.getLogin().trim();
|
||||
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login);
|
||||
if (user == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
|
||||
List<UserSettingEntry> entries = UserSettingsDAO.getInstance().getByLogin(c, login);
|
||||
List<Net_ListUserSettings_Response.Item> items = new ArrayList<>();
|
||||
for (UserSettingEntry e : entries) {
|
||||
Net_ListUserSettings_Response.Item item = new Net_ListUserSettings_Response.Item();
|
||||
item.setLogin(e.getLogin());
|
||||
item.setSetting_type(e.getSettingType());
|
||||
item.setSetting_key(e.getSettingKey());
|
||||
item.setTime_ms(e.getTimeMs());
|
||||
item.setValue_text(e.getValueText());
|
||||
item.setValue_num(e.getValueNum());
|
||||
item.setClient_key(e.getClientKey());
|
||||
item.setSignature(e.getSignature());
|
||||
item.setSynced(e.isSynced());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
Net_ListUserSettings_Response resp = new Net_ListUserSettings_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(user.getLogin());
|
||||
resp.setSettings(items);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("ListUserSettings failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings;
|
||||
|
||||
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.userSettings.entyties.Net_UpsertUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.RemoteUserSettingsSyncClient;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
import utils.config.ShineSignatureConstants;
|
||||
import utils.config.AppConfig;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserSetting_Handler.class);
|
||||
private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient();
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_UpsertUserSetting_Request req = (Net_UpsertUserSetting_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getSetting_type() == null
|
||||
|| req.getSetting_key() == null || req.getSetting_key().isBlank()
|
||||
|| 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",
|
||||
"Некорректные поля: login/setting_type/setting_key/time_ms/client_key/signature");
|
||||
}
|
||||
|
||||
String login = req.getLogin().trim();
|
||||
int settingType = req.getSetting_type();
|
||||
String settingKey = req.getSetting_key().trim();
|
||||
long timeMs = req.getTime_ms();
|
||||
String valueText = req.getValue_text() == null ? "" : req.getValue_text();
|
||||
long valueNum = req.getValue_num() == null ? 0L : req.getValue_num();
|
||||
String clientKeyB64 = req.getClient_key().trim();
|
||||
String signatureB64 = req.getSignature().trim();
|
||||
boolean syncDelivery = Boolean.TRUE.equals(req.getSync_delivery());
|
||||
|
||||
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 signText = ShineSignatureConstants.USER_SETTINGS_PREFIX
|
||||
+ escapePart(login) + '|'
|
||||
+ settingType + '|'
|
||||
+ escapePart(settingKey) + '|'
|
||||
+ timeMs + '|'
|
||||
+ escapePart(valueText) + '|'
|
||||
+ valueNum;
|
||||
|
||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
|
||||
}
|
||||
|
||||
DbController db = DbController.getInstance();
|
||||
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
||||
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
CurrentUserEntry user = usersDAO.getByLogin(c, login);
|
||||
if (user == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
String userClientKey = user.getClientKey();
|
||||
if (userClientKey == null || userClientKey.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "USER_DEVICE_KEY_EMPTY", "У пользователя не задан clientKey в БД");
|
||||
}
|
||||
if (!userClientKey.trim().equals(clientKeyB64)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||
}
|
||||
|
||||
UserSettingEntry entry = new UserSettingEntry(
|
||||
login,
|
||||
settingType,
|
||||
settingKey,
|
||||
timeMs,
|
||||
valueText,
|
||||
valueNum,
|
||||
clientKeyB64,
|
||||
signatureB64,
|
||||
syncDelivery
|
||||
);
|
||||
int changed = settingsDAO.upsertIfNewer(c, entry);
|
||||
|
||||
if (!syncDelivery && changed > 0) {
|
||||
int delivered = 0;
|
||||
String ownServerLogin = String.valueOf(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG) == null ? "" : AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)).trim();
|
||||
List<UserAccessServerRouteEntry> routes = UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, login);
|
||||
for (UserAccessServerRouteEntry route : routes) {
|
||||
if (route == null) continue;
|
||||
String remoteLogin = String.valueOf(route.getServerLogin() == null ? "" : route.getServerLogin()).trim();
|
||||
String remoteUrl = String.valueOf(route.getServerUrl() == null ? "" : route.getServerUrl()).trim();
|
||||
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
|
||||
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
|
||||
try {
|
||||
REMOTE.upsertUserSetting(remoteUrl, entry, true);
|
||||
delivered++;
|
||||
} catch (Exception e) {
|
||||
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
|
||||
}
|
||||
}
|
||||
if (delivered > 0 || routes.isEmpty()) {
|
||||
settingsDAO.markSynced(c, login, settingType, settingKey);
|
||||
}
|
||||
}
|
||||
|
||||
Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setSetting_type(settingType);
|
||||
resp.setSetting_key(settingKey);
|
||||
resp.setTime_ms(timeMs);
|
||||
resp.setSynced(syncDelivery || changed == 0);
|
||||
return resp;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
log.error("UpsertUserSetting DB error", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "DB_ERROR", "Ошибка БД");
|
||||
} catch (Exception e) {
|
||||
log.error("UpsertUserSetting failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserSetting", e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapePart(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value);
|
||||
return s.replace("\\", "\\\\").replace("|", "\\|");
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetUserSetting_Request extends Net_Request {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_GetUserSetting_Response extends Net_Response {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
private Long time_ms;
|
||||
private String value_text;
|
||||
private Long value_num;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
private Boolean synced;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getValue_text() { return value_text; }
|
||||
public void setValue_text(String value_text) { this.value_text = value_text; }
|
||||
|
||||
public Long getValue_num() { return value_num; }
|
||||
public void setValue_num(Long value_num) { this.value_num = value_num; }
|
||||
|
||||
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; }
|
||||
|
||||
public Boolean getSynced() { return synced; }
|
||||
public void setSynced(Boolean synced) { this.synced = synced; }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_ListUserSettings_Request extends Net_Request {
|
||||
private String login;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListUserSettings_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<Item> settings = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public List<Item> getSettings() { return settings; }
|
||||
public void setSettings(List<Item> settings) { this.settings = settings; }
|
||||
|
||||
public static class Item {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
private Long time_ms;
|
||||
private String value_text;
|
||||
private Long value_num;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
private Boolean synced;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
public String getValue_text() { return value_text; }
|
||||
public void setValue_text(String value_text) { this.value_text = value_text; }
|
||||
public Long getValue_num() { return value_num; }
|
||||
public void setValue_num(Long value_num) { this.value_num = value_num; }
|
||||
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; }
|
||||
public Boolean getSynced() { return synced; }
|
||||
public void setSynced(Boolean synced) { this.synced = synced; }
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_UpsertUserSetting_Request extends Net_Request {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
private Long time_ms;
|
||||
private String value_text;
|
||||
private Long value_num;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
private Boolean sync_delivery;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getValue_text() { return value_text; }
|
||||
public void setValue_text(String value_text) { this.value_text = value_text; }
|
||||
|
||||
public Long getValue_num() { return value_num; }
|
||||
public void setValue_num(Long value_num) { this.value_num = value_num; }
|
||||
|
||||
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; }
|
||||
|
||||
public Boolean getSync_delivery() { return sync_delivery; }
|
||||
public void setSync_delivery(Boolean sync_delivery) { this.sync_delivery = sync_delivery; }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_UpsertUserSetting_Response extends Net_Response {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
private Long time_ms;
|
||||
private Boolean synced;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public Boolean getSynced() { return synced; }
|
||||
public void setSynced(Boolean synced) { this.synced = synced; }
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
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.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_MarkAllUserSettingsUnsynced_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest;
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
int updated;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||||
updated = UserSettingsDAO.getInstance().markAllUnsynced(c);
|
||||
} else {
|
||||
updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim());
|
||||
}
|
||||
Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setUpdated(updated);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("MarkAllUserSettingsUnsynced failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
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.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.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
import shine.db.dao.DirectMessagesDAO;
|
||||
import shine.db.dao.SignedDirectMessagesHistoryDAO;
|
||||
import shine.db.dao.SignedDmReplayDAO;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final long REPLAY_TTL_MS = 15L * 60L * 1000L;
|
||||
private static final int MAX_MESSAGE_BYTES = 3000;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
|
||||
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
|
||||
}
|
||||
|
||||
final byte[] raw;
|
||||
final SignedDirectMessagePacket packet;
|
||||
try {
|
||||
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
|
||||
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
|
||||
}
|
||||
|
||||
CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin);
|
||||
CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin);
|
||||
if (fromUser == null || toUser == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден");
|
||||
}
|
||||
|
||||
byte[] publicKey32;
|
||||
try {
|
||||
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя");
|
||||
}
|
||||
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
|
||||
}
|
||||
|
||||
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
|
||||
if (!replayOk) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано");
|
||||
}
|
||||
|
||||
String messageId = NetIdGenerator.eventId("msg");
|
||||
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
|
||||
|
||||
DirectMessageEntry entry = new DirectMessageEntry();
|
||||
entry.setMessageId(messageId);
|
||||
entry.setFromLogin(packet.fromLogin);
|
||||
entry.setToLogin(packet.toLogin);
|
||||
entry.setText(textForUi);
|
||||
entry.setCreatedAtMs(now);
|
||||
DirectMessagesDAO.getInstance().insert(entry);
|
||||
|
||||
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
|
||||
history.setMessageId(messageId);
|
||||
history.setFromLogin(packet.fromLogin);
|
||||
history.setToLogin(packet.toLogin);
|
||||
history.setTargetMode(packet.targetMode);
|
||||
history.setTargetSessionId(packet.targetSessionId);
|
||||
history.setMessageType(packet.messageType);
|
||||
history.setTimeMs(packet.timeMs);
|
||||
history.setNonce(packet.nonce);
|
||||
history.setRawPacket(packet.rawPacket);
|
||||
history.setCreatedAtMs(now);
|
||||
SignedDirectMessagesHistoryDAO.getInstance().insert(history);
|
||||
|
||||
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
|
||||
|
||||
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setMessageId(messageId);
|
||||
resp.setDeliveredWsSessions(delivery.wsDelivered);
|
||||
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
|
||||
resp.setSessionNotFound(delivery.sessionNotFound);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
|
||||
DeliveryResult result = new DeliveryResult();
|
||||
|
||||
Set<String> selectedSessionIds = new HashSet<>();
|
||||
if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) {
|
||||
ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId);
|
||||
if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) {
|
||||
result.sessionNotFound = true;
|
||||
return result;
|
||||
}
|
||||
selectedSessionIds.add(byId.getSessionId());
|
||||
deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin);
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
selectedSessionIds.add(s.getSessionId());
|
||||
deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void deliverToSession(
|
||||
SignedDirectMessagePacket packet,
|
||||
String blobB64,
|
||||
String messageId,
|
||||
long createdAtMs,
|
||||
String sessionId,
|
||||
DeliveryResult result
|
||||
) {
|
||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
||||
boolean wsDelivered = false;
|
||||
if (targetCtx != null) {
|
||||
String eventId = NetIdGenerator.eventId("evt");
|
||||
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("fromLogin", packet.fromLogin);
|
||||
payload.put("toLogin", packet.toLogin);
|
||||
payload.put("blobB64", blobB64);
|
||||
payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8));
|
||||
payload.put("timeMs", createdAtMs);
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
|
||||
if (sent) {
|
||||
try {
|
||||
wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception ignored) {
|
||||
wsDelivered = false;
|
||||
}
|
||||
}
|
||||
DeliveryTracker.getInstance().remove(eventId);
|
||||
}
|
||||
|
||||
if (wsDelivered) {
|
||||
result.wsDelivered++;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
|
||||
if (targetSession == null) return;
|
||||
if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) {
|
||||
return;
|
||||
}
|
||||
boolean pushed = WebPushSender.sendBase64Payload(
|
||||
targetSession.getPushEndpoint(),
|
||||
targetSession.getPushP256dhKey(),
|
||||
targetSession.getPushAuthKey(),
|
||||
blobB64
|
||||
);
|
||||
if (pushed) result.webPushDelivered++;
|
||||
} catch (Exception ignored) {
|
||||
// ignore per-session push errors
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static final class DeliveryResult {
|
||||
int wsDelivered;
|
||||
int webPushDelivered;
|
||||
boolean sessionNotFound;
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
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.messages.entyties.Net_UserSettingsSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_UserSettingsSyncBatch_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_UserSettingsSyncBatch_Handler.class);
|
||||
private static final int DEFAULT_LIMIT = 500;
|
||||
private static final int MAX_LIMIT = 1000;
|
||||
private static final int DEFAULT_MAX_BYTES = 3_000_000;
|
||||
private static final int MAX_BYTES_CAP = 5_000_000;
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_UserSettingsSyncBatch_Request req = (Net_UserSettingsSyncBatch_Request) baseRequest;
|
||||
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
|
||||
if (ownerLogin == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
|
||||
}
|
||||
|
||||
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
if (ownServerLogin == null) {
|
||||
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
|
||||
}
|
||||
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
|
||||
}
|
||||
|
||||
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
|
||||
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
|
||||
long afterTimeMs = Math.max(0L, req.getAfterTimeMs() == null ? 0L : req.getAfterTimeMs());
|
||||
String afterSettingKey = req.getAfterSettingKey() == null ? "" : req.getAfterSettingKey().trim();
|
||||
|
||||
List<UserSettingEntry> batch;
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
batch = UserSettingsDAO.getInstance().listNewerThan(
|
||||
c,
|
||||
ownerLogin,
|
||||
afterTimeMs,
|
||||
afterSettingKey,
|
||||
limit
|
||||
);
|
||||
}
|
||||
|
||||
Net_UserSettingsSyncBatch_Response resp = new Net_UserSettingsSyncBatch_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setOwnerLogin(ownerLogin);
|
||||
resp.setLimit(limit);
|
||||
|
||||
int rawBytes = 0;
|
||||
List<Net_UserSettingsSyncBatch_Response.Item> items = new ArrayList<>();
|
||||
for (UserSettingEntry entry : batch) {
|
||||
Net_UserSettingsSyncBatch_Response.Item item = new Net_UserSettingsSyncBatch_Response.Item();
|
||||
item.setLogin(entry.getLogin());
|
||||
item.setSetting_type(entry.getSettingType());
|
||||
item.setSetting_key(entry.getSettingKey());
|
||||
item.setTime_ms(entry.getTimeMs());
|
||||
item.setValue_text(entry.getValueText());
|
||||
item.setValue_num(entry.getValueNum());
|
||||
item.setClient_key(entry.getClientKey());
|
||||
item.setSignature(entry.getSignature());
|
||||
item.setSynced(entry.isSynced());
|
||||
items.add(item);
|
||||
rawBytes += String.valueOf(entry.getLogin()).length()
|
||||
+ String.valueOf(entry.getSettingKey()).length()
|
||||
+ String.valueOf(entry.getValueText()).length()
|
||||
+ String.valueOf(entry.getClientKey() == null ? "" : entry.getClientKey()).length()
|
||||
+ String.valueOf(entry.getSignature() == null ? "" : entry.getSignature()).length()
|
||||
+ 64;
|
||||
if (rawBytes > maxBytes) break;
|
||||
resp.setNextTimeMs(entry.getTimeMs());
|
||||
resp.setNextSettingKey(entry.getSettingKey());
|
||||
}
|
||||
resp.setRawBytes(rawBytes);
|
||||
resp.setHasMore(batch.size() > items.size());
|
||||
resp.setItems(items);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, ownerLogin)) {
|
||||
if (route == null || route.getServerLogin() == null) continue;
|
||||
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim().toLowerCase();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private static String normalizeOriginal(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserSettingsSyncApplySupport {
|
||||
private UserSettingsSyncApplySupport() {}
|
||||
|
||||
public static ApplyResult applySyncedItem(Connection c, String ownerLogin, Net_UserSettingsSyncBatch_Response.Item item) throws Exception {
|
||||
if (item == null) return new ApplyResult(false, "empty_item");
|
||||
String login = normalize(item.getLogin());
|
||||
String key = normalize(item.getSetting_key());
|
||||
if (login == null || key == null) return new ApplyResult(false, "bad_item");
|
||||
|
||||
UserSettingEntry entry = new UserSettingEntry(
|
||||
login,
|
||||
item.getSetting_type() == null ? 0 : item.getSetting_type(),
|
||||
key,
|
||||
item.getTime_ms() == null ? 0L : item.getTime_ms(),
|
||||
item.getValue_text() == null ? "" : item.getValue_text(),
|
||||
item.getValue_num() == null ? 0L : item.getValue_num(),
|
||||
item.getClient_key(),
|
||||
item.getSignature(),
|
||||
true
|
||||
);
|
||||
int changed = UserSettingsDAO.getInstance().upsertIfNewer(c, entry);
|
||||
return new ApplyResult(changed > 0, changed > 0 ? "applied" : "ignored");
|
||||
}
|
||||
|
||||
public static List<UserSettingEntry> toEntries(List<Net_UserSettingsSyncBatch_Response.Item> items) {
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
if (items == null) return out;
|
||||
for (Net_UserSettingsSyncBatch_Response.Item item : items) {
|
||||
if (item == null) continue;
|
||||
out.add(new UserSettingEntry(
|
||||
normalize(item.getLogin()),
|
||||
item.getSetting_type() == null ? 0 : item.getSetting_type(),
|
||||
normalize(item.getSetting_key()),
|
||||
item.getTime_ms() == null ? 0L : item.getTime_ms(),
|
||||
item.getValue_text() == null ? "" : item.getValue_text(),
|
||||
item.getValue_num() == null ? 0L : item.getValue_num(),
|
||||
item.getClient_key(),
|
||||
item.getSignature(),
|
||||
true
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value).trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
public record ApplyResult(boolean applied, String status) {}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_MarkAllUserSettingsUnsynced_Request extends Net_Request {
|
||||
private String login;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response {
|
||||
private Integer updated;
|
||||
|
||||
public Integer getUpdated() { return updated; }
|
||||
public void setUpdated(Integer updated) { this.updated = updated; }
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SendDirectMessage_Request extends Net_Request {
|
||||
private String blobB64;
|
||||
|
||||
public String getBlobB64() { return blobB64; }
|
||||
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SendDirectMessage_Response extends Net_Response {
|
||||
private String messageId;
|
||||
private int deliveredWsSessions;
|
||||
private int deliveredWebPushSessions;
|
||||
private boolean sessionNotFound;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||
public boolean isSessionNotFound() { return sessionNotFound; }
|
||||
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_UserSettingsSyncBatch_Request extends Net_Request {
|
||||
private String ownerLogin;
|
||||
private Long afterTimeMs;
|
||||
private String afterSettingKey;
|
||||
private Integer limit;
|
||||
private Integer maxBytes;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
|
||||
public Long getAfterTimeMs() { return afterTimeMs; }
|
||||
public void setAfterTimeMs(Long afterTimeMs) { this.afterTimeMs = afterTimeMs; }
|
||||
|
||||
public String getAfterSettingKey() { return afterSettingKey; }
|
||||
public void setAfterSettingKey(String afterSettingKey) { this.afterSettingKey = afterSettingKey; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public Integer getMaxBytes() { return maxBytes; }
|
||||
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_UserSettingsSyncBatch_Response extends Net_Response {
|
||||
private String ownerLogin;
|
||||
private Integer limit;
|
||||
private Integer rawBytes;
|
||||
private Boolean hasMore;
|
||||
private Long nextTimeMs;
|
||||
private String nextSettingKey;
|
||||
private List<Item> items = new ArrayList<>();
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
public Integer getRawBytes() { return rawBytes; }
|
||||
public void setRawBytes(Integer rawBytes) { this.rawBytes = rawBytes; }
|
||||
public Boolean getHasMore() { return hasMore; }
|
||||
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
|
||||
public Long getNextTimeMs() { return nextTimeMs; }
|
||||
public void setNextTimeMs(Long nextTimeMs) { this.nextTimeMs = nextTimeMs; }
|
||||
public String getNextSettingKey() { return nextSettingKey; }
|
||||
public void setNextSettingKey(String nextSettingKey) { this.nextSettingKey = nextSettingKey; }
|
||||
public List<Item> getItems() { return items; }
|
||||
public void setItems(List<Item> items) { this.items = items; }
|
||||
|
||||
public static class Item {
|
||||
private String login;
|
||||
private Integer setting_type;
|
||||
private String setting_key;
|
||||
private Long time_ms;
|
||||
private String value_text;
|
||||
private Long value_num;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
private Boolean synced;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public Integer getSetting_type() { return setting_type; }
|
||||
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
|
||||
public String getSetting_key() { return setting_key; }
|
||||
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
public String getValue_text() { return value_text; }
|
||||
public void setValue_text(String value_text) { this.value_text = value_text; }
|
||||
public Long getValue_num() { return value_num; }
|
||||
public void setValue_num(Long value_num) { this.value_num = value_num; }
|
||||
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; }
|
||||
public Boolean getSynced() { return synced; }
|
||||
public void setSynced(Boolean synced) { this.synced = synced; }
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package server.sync;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class RemoteUserSettingsSyncClient {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
|
||||
public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
{
|
||||
"op":"UpsertUserSetting",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"login":%s,
|
||||
"setting_type":%d,
|
||||
"setting_key":%s,
|
||||
"time_ms":%d,
|
||||
"value_text":%s,
|
||||
"value_num":%d,
|
||||
"client_key":%s,
|
||||
"signature":%s,
|
||||
"sync_delivery":%s
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
"%s",
|
||||
MAPPER.writeValueAsString(entry.getLogin()),
|
||||
entry.getSettingType(),
|
||||
MAPPER.writeValueAsString(entry.getSettingKey()),
|
||||
entry.getTimeMs(),
|
||||
MAPPER.writeValueAsString(entry.getValueText() == null ? "" : entry.getValueText()),
|
||||
entry.getValueNum(),
|
||||
MAPPER.writeValueAsString(entry.getClientKey() == null ? "" : entry.getClientKey()),
|
||||
MAPPER.writeValueAsString(entry.getSignature() == null ? "" : entry.getSignature()),
|
||||
syncDelivery ? "true" : "false"
|
||||
));
|
||||
ensureOk("UpsertUserSetting", response);
|
||||
}
|
||||
|
||||
public RemoteUserSettingsBatch userSettingsSyncBatch(
|
||||
String serverAddressRaw,
|
||||
String ownerLogin,
|
||||
long afterTimeMs,
|
||||
String afterSettingKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
{
|
||||
"op":"UserSettingsSyncBatch",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"ownerLogin":%s,
|
||||
"afterTimeMs":%d,
|
||||
"afterSettingKey":%s,
|
||||
"limit":%d,
|
||||
"maxBytes":%d
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
"%s",
|
||||
MAPPER.writeValueAsString(ownerLogin),
|
||||
Math.max(0L, afterTimeMs),
|
||||
MAPPER.writeValueAsString(afterSettingKey == null ? "" : afterSettingKey),
|
||||
limit,
|
||||
maxBytes
|
||||
));
|
||||
ensureOk("UserSettingsSyncBatch", response);
|
||||
|
||||
JsonNode payload = response.path("payload");
|
||||
List<RemoteUserSettingsItem> items = new ArrayList<>();
|
||||
JsonNode arr = payload.path("items");
|
||||
if (arr.isArray()) {
|
||||
for (JsonNode item : arr) {
|
||||
items.add(new RemoteUserSettingsItem(
|
||||
item.path("login").asText(""),
|
||||
item.path("setting_type").asInt(0),
|
||||
item.path("setting_key").asText(""),
|
||||
item.path("time_ms").asLong(0L),
|
||||
item.path("value_text").asText(""),
|
||||
item.path("value_num").asLong(0L),
|
||||
item.path("client_key").asText(""),
|
||||
item.path("signature").asText("")
|
||||
));
|
||||
}
|
||||
}
|
||||
return new RemoteUserSettingsBatch(
|
||||
payload.path("nextTimeMs").asLong(afterTimeMs),
|
||||
payload.path("nextSettingKey").asText(afterSettingKey == null ? "" : afterSettingKey),
|
||||
payload.path("hasMore").asBoolean(false),
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
|
||||
String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID());
|
||||
String json = jsonTemplate.formatted(requestId);
|
||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
||||
if (wsUrl == null) {
|
||||
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
||||
}
|
||||
|
||||
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||
CountDownLatch openLatch = new CountDownLatch(1);
|
||||
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||
|
||||
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.buildAsync(URI.create(wsUrl), listener)
|
||||
.get(8, TimeUnit.SECONDS);
|
||||
|
||||
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||
tryAbort(webSocket);
|
||||
throw new TimeoutException("WS open timeout");
|
||||
}
|
||||
|
||||
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||
tryAbort(webSocket);
|
||||
return MAPPER.readTree(responseJson);
|
||||
}
|
||||
|
||||
private void ensureOk(String op, JsonNode response) {
|
||||
int status = response.path("status").asInt(500);
|
||||
if (status >= 200 && status < 300) return;
|
||||
String code = response.path("code").asText("");
|
||||
if (code.isBlank()) code = response.path("error").asText("");
|
||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
||||
}
|
||||
|
||||
private static void tryAbort(WebSocket webSocket) {
|
||||
try {
|
||||
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
webSocket.abort();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoteUserSettingsBatch(
|
||||
long nextTimeMs,
|
||||
String nextSettingKey,
|
||||
boolean hasMore,
|
||||
List<RemoteUserSettingsItem> items
|
||||
) {}
|
||||
|
||||
public record RemoteUserSettingsItem(
|
||||
String login,
|
||||
int settingType,
|
||||
String settingKey,
|
||||
long timeMs,
|
||||
String valueText,
|
||||
long valueNum,
|
||||
String clientKey,
|
||||
String signature
|
||||
) {}
|
||||
|
||||
private static final class SyncWsListener implements WebSocket.Listener {
|
||||
private final CompletableFuture<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
|
||||
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
|
||||
this.responseFuture = responseFuture;
|
||||
this.openLatch = openLatch;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
openLatch.countDown();
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
textBuffer.append(data);
|
||||
if (last && !responseFuture.isDone()) {
|
||||
responseFuture.complete(textBuffer.toString());
|
||||
}
|
||||
webSocket.request(1);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
responseFuture.completeExceptionally(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-31
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class PostgresStorageRepository
|
||||
implements AutoCloseable {
|
||||
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
|
||||
|
||||
String sql =
|
||||
"INSERT INTO solana_user_pda_current (" +
|
||||
"pda_address, login, record_number, slot, last_tx_signature, " +
|
||||
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
|
||||
"recovery_key, root_key, client_key, blockchain_name, " +
|
||||
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
||||
"last_block_number, last_block_hash, last_block_signature, " +
|
||||
@@ -475,9 +476,10 @@ public final class PostgresStorageRepository
|
||||
"trusted_count, created_at_ms, updated_at_ms, " +
|
||||
"prev_record_hash, record_signature, raw_data_base64, " +
|
||||
"first_seen_at_ms, last_synced_at_ms" +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
||||
"login = EXCLUDED.login, " +
|
||||
"normalized_login = EXCLUDED.normalized_login, " +
|
||||
"record_number = EXCLUDED.record_number, " +
|
||||
"slot = EXCLUDED.slot, " +
|
||||
"last_tx_signature = EXCLUDED.last_tx_signature, " +
|
||||
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
|
||||
|
||||
statement.setString(1, snapshot.pdaAddress());
|
||||
statement.setString(2, snapshot.login());
|
||||
statement.setInt(3, snapshot.recordNumber());
|
||||
statement.setLong(4, snapshot.slot());
|
||||
statement.setString(5, snapshot.lastTxSignature());
|
||||
statement.setString(6, snapshot.recoveryKey());
|
||||
statement.setString(7, snapshot.rootKey());
|
||||
statement.setString(8, snapshot.clientKey());
|
||||
statement.setString(9, snapshot.blockchainName());
|
||||
statement.setString(10, snapshot.blockchainKey());
|
||||
statement.setLong(11, snapshot.paidLimitBytes());
|
||||
statement.setLong(12, snapshot.usedBytes());
|
||||
statement.setInt(13, snapshot.lastBlockNumber());
|
||||
statement.setString(14, snapshot.lastBlockHash());
|
||||
statement.setString(15, snapshot.lastBlockSignature());
|
||||
statement.setString(16, snapshot.arweaveTxId());
|
||||
statement.setBoolean(17, snapshot.isServer());
|
||||
statement.setInt(18, snapshot.addressFormatType());
|
||||
statement.setInt(19, snapshot.addressFormatVersion());
|
||||
statement.setString(20, snapshot.serverAddress());
|
||||
statement.setString(21, writeJson(snapshot.syncServers()));
|
||||
statement.setString(22, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(23, snapshot.sessionsMode());
|
||||
statement.setString(24, writeJson(snapshot.sessions()));
|
||||
statement.setInt(25, snapshot.trustedCount());
|
||||
statement.setLong(26, snapshot.createdAtMs());
|
||||
statement.setLong(27, snapshot.updatedAtMs());
|
||||
statement.setString(28, snapshot.prevRecordHash());
|
||||
statement.setString(29, snapshot.recordSignature());
|
||||
statement.setString(30, snapshot.rawDataBase64());
|
||||
statement.setLong(31, nowMs);
|
||||
statement.setString(3, normalizeLogin(snapshot.login()));
|
||||
statement.setInt(4, snapshot.recordNumber());
|
||||
statement.setLong(5, snapshot.slot());
|
||||
statement.setString(6, snapshot.lastTxSignature());
|
||||
statement.setString(7, snapshot.recoveryKey());
|
||||
statement.setString(8, snapshot.rootKey());
|
||||
statement.setString(9, snapshot.clientKey());
|
||||
statement.setString(10, snapshot.blockchainName());
|
||||
statement.setString(11, snapshot.blockchainKey());
|
||||
statement.setLong(12, snapshot.paidLimitBytes());
|
||||
statement.setLong(13, snapshot.usedBytes());
|
||||
statement.setInt(14, snapshot.lastBlockNumber());
|
||||
statement.setString(15, snapshot.lastBlockHash());
|
||||
statement.setString(16, snapshot.lastBlockSignature());
|
||||
statement.setString(17, snapshot.arweaveTxId());
|
||||
statement.setBoolean(18, snapshot.isServer());
|
||||
statement.setInt(19, snapshot.addressFormatType());
|
||||
statement.setInt(20, snapshot.addressFormatVersion());
|
||||
statement.setString(21, snapshot.serverAddress());
|
||||
statement.setString(22, writeJson(snapshot.syncServers()));
|
||||
statement.setString(23, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(24, snapshot.sessionsMode());
|
||||
statement.setString(25, writeJson(snapshot.sessions()));
|
||||
statement.setInt(26, snapshot.trustedCount());
|
||||
statement.setLong(27, snapshot.createdAtMs());
|
||||
statement.setLong(28, snapshot.updatedAtMs());
|
||||
statement.setString(29, snapshot.prevRecordHash());
|
||||
statement.setString(30, snapshot.recordSignature());
|
||||
statement.setString(31, snapshot.rawDataBase64());
|
||||
statement.setLong(32, nowMs);
|
||||
statement.setLong(33, nowMs);
|
||||
}
|
||||
|
||||
private String normalizeLogin(String login) {
|
||||
return login == null ? "" : login.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
|
||||
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
|
||||
"pda_address TEXT PRIMARY KEY, " +
|
||||
"login TEXT NOT NULL UNIQUE, " +
|
||||
"normalized_login TEXT NOT NULL, " +
|
||||
"record_number INTEGER NOT NULL, " +
|
||||
"slot BIGINT NOT NULL, " +
|
||||
"last_tx_signature TEXT NOT NULL, " +
|
||||
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
|
||||
"last_synced_at_ms BIGINT NOT NULL" +
|
||||
")"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"ALTER TABLE solana_user_pda_current " +
|
||||
"ADD COLUMN IF NOT EXISTS normalized_login TEXT"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"UPDATE solana_user_pda_current " +
|
||||
"SET normalized_login = LOWER(BTRIM(login)) " +
|
||||
"WHERE normalized_login IS NULL " +
|
||||
" OR normalized_login <> LOWER(BTRIM(login))"
|
||||
);
|
||||
|
||||
statement.executeUpdate(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
|
||||
"ON solana_user_pda_current(slot)"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " +
|
||||
"ON solana_user_pda_current(normalized_login)"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " +
|
||||
"ON solana_user_pda_current(normalized_login)"
|
||||
);
|
||||
|
||||
statement.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package server.sync;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.dao.UserSettingsSyncPeerStateDAO;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
import shine.db.entities.UserSettingsSyncPeerStateEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class PeriodicUserSettingsSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(PeriodicUserSettingsSyncService.class);
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient();
|
||||
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
|
||||
private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance();
|
||||
private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance();
|
||||
|
||||
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "periodic-user-settings-sync");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
private PeriodicUserSettingsSyncService() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Periodic user settings sync disabled by user.settings.sync.enabled=false");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) return;
|
||||
long initialDelaySec = configLong("user.settings.sync.initialDelaySeconds", 90L, 0L, 3600L);
|
||||
long periodHours = configLong("user.settings.sync.periodHours", 6L, 1L, 168L);
|
||||
EXECUTOR.scheduleWithFixedDelay(
|
||||
PeriodicUserSettingsSyncService::runCycleSafe,
|
||||
initialDelaySec,
|
||||
TimeUnit.HOURS.toSeconds(periodHours),
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
|
||||
}
|
||||
|
||||
private static void runCycleSafe() {
|
||||
try {
|
||||
runCycle();
|
||||
} catch (Exception e) {
|
||||
log.error("Periodic user settings sync failed unexpectedly", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runCycle() throws Exception {
|
||||
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
if (ownServerLogin == null) {
|
||||
log.warn("Periodic user settings sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
|
||||
Set<String> owners = new LinkedHashSet<>(ownersRaw);
|
||||
if (owners.isEmpty()) {
|
||||
log.info("Periodic user settings sync skipped: no local access-server users for {}", ownServerLogin);
|
||||
return;
|
||||
}
|
||||
|
||||
int syncedPeers = 0;
|
||||
int appliedItems = 0;
|
||||
int pushedItems = 0;
|
||||
for (String ownerLogin : owners) {
|
||||
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
|
||||
for (UserAccessServerRouteEntry route : routes) {
|
||||
if (route == null) continue;
|
||||
String remoteLogin = normalize(route.getServerLogin());
|
||||
String remoteUrl = route.getServerUrl();
|
||||
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
|
||||
if (remoteLogin.equals(ownServerLogin)) continue;
|
||||
|
||||
try {
|
||||
SyncStats stats = syncOwnerWithRemote(ownerLogin, route);
|
||||
appliedItems += stats.applied();
|
||||
pushedItems += stats.pushed();
|
||||
syncedPeers++;
|
||||
} catch (Exception e) {
|
||||
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
|
||||
log.warn("Periodic user settings sync peer failed: owner={} remoteServer={} reason={}",
|
||||
ownerLogin, route.getServerLogin(), String.valueOf(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}",
|
||||
owners.size(), syncedPeers, appliedItems, pushedItems);
|
||||
}
|
||||
|
||||
private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
|
||||
int limit = (int) configLong("user.settings.sync.batchLimit", 500L, 1L, 1000L);
|
||||
int maxBytes = (int) configLong("user.settings.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
|
||||
int maxPages = (int) configLong("user.settings.sync.maxPagesPerPeer", 50L, 1L, 500L);
|
||||
|
||||
UserSettingsSyncPeerStateEntry state;
|
||||
try (Connection c = getDbConnection()) {
|
||||
state = STATE_DAO.getOrCreate(c, ownerLogin, route.getServerLogin(), route.getServerUrl());
|
||||
}
|
||||
long cursorTimeMs = state.getCursorTimeMs();
|
||||
String cursorSettingKey = state.getCursorSettingKey() == null ? "" : state.getCursorSettingKey();
|
||||
int applied = 0;
|
||||
int pushed = 0;
|
||||
boolean bootstrapCompleted = false;
|
||||
|
||||
for (int page = 0; page < maxPages; page++) {
|
||||
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
|
||||
route.getServerUrl(),
|
||||
ownerLogin,
|
||||
cursorTimeMs,
|
||||
cursorSettingKey,
|
||||
limit,
|
||||
maxBytes
|
||||
);
|
||||
|
||||
try (Connection c = getDbConnection()) {
|
||||
for (RemoteUserSettingsSyncClient.RemoteUserSettingsItem item : batch.items()) {
|
||||
UserSettingEntry entry = new UserSettingEntry(
|
||||
item.login(),
|
||||
item.settingType(),
|
||||
item.settingKey(),
|
||||
item.timeMs(),
|
||||
item.valueText(),
|
||||
item.valueNum(),
|
||||
item.clientKey(),
|
||||
item.signature(),
|
||||
true
|
||||
);
|
||||
int changed = SETTINGS_DAO.upsertIfNewer(c, entry);
|
||||
if (changed > 0) applied++;
|
||||
}
|
||||
}
|
||||
|
||||
cursorTimeMs = Math.max(cursorTimeMs, batch.nextTimeMs());
|
||||
cursorSettingKey = batch.nextSettingKey() == null ? "" : batch.nextSettingKey();
|
||||
bootstrapCompleted = !batch.hasMore();
|
||||
STATE_DAO.updateSuccess(ownerLogin, route.getServerLogin(), route.getServerUrl(), cursorTimeMs, cursorSettingKey, bootstrapCompleted);
|
||||
|
||||
if (!batch.hasMore() || batch.items().isEmpty()) break;
|
||||
}
|
||||
|
||||
try (Connection c = getDbConnection()) {
|
||||
List<UserSettingEntry> unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit);
|
||||
for (UserSettingEntry entry : unsynced) {
|
||||
REMOTE.upsertUserSetting(route.getServerUrl(), entry, true);
|
||||
SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey());
|
||||
pushed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bootstrapCompleted) {
|
||||
log.info("Periodic user settings sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
|
||||
ownerLogin, route.getServerLogin(), maxPages);
|
||||
}
|
||||
return new SyncStats(applied, pushed);
|
||||
}
|
||||
|
||||
private static java.sql.Connection getDbConnection() throws Exception {
|
||||
return shine.db.DbController.getInstance().getConnection();
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
String raw = AppConfig.getInstance().getParam("user.settings.sync.enabled");
|
||||
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
|
||||
}
|
||||
|
||||
private static long configLong(String key, long defaultValue, long min, long max) {
|
||||
String raw = AppConfig.getInstance().getParam(key);
|
||||
if (raw == null || raw.isBlank()) return defaultValue;
|
||||
try {
|
||||
long parsed = Long.parseLong(raw.trim());
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private record SyncStats(int applied, int pushed) {}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import server.debug.DebugApiConfigurator;
|
||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||
import server.sync.PeriodicBlockchainSyncService;
|
||||
import server.sync.PeriodicDmSyncService;
|
||||
import server.sync.PeriodicUserSettingsSyncService;
|
||||
import server.sync.SolanaUsersSyncStartupService;
|
||||
import server.sync.SyncServersBootstrapService;
|
||||
import utils.config.AppConfig;
|
||||
@@ -104,6 +105,7 @@ public final class WsServer {
|
||||
server.start();
|
||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||
PeriodicDmSyncService.startOrLog();
|
||||
PeriodicUserSettingsSyncService.startOrLog();
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: FRIEND", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: FRIEND", t);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# TODO: уведомления и full-resync после фиксации блоков в Arweave
|
||||
|
||||
## Контекст
|
||||
|
||||
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
|
||||
|
||||
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
|
||||
|
||||
## Что проверить после внедрения Arweave
|
||||
|
||||
Когда схема Arweave и правила восстановления цепочки стабилизируются:
|
||||
|
||||
- определить окончательный source of truth для accepted/finalized blocks;
|
||||
- проверить поведение `user_notifications_state` при rollback/full-resync;
|
||||
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
|
||||
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
|
||||
- добавить интеграционный тест на divergence + full-resync + notification projection.
|
||||
|
||||
## Важно
|
||||
|
||||
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.7.0
|
||||
server.version=1.5.0
|
||||
server.version=1.6.1
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import org.bouncycastle.util.Properties
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'application'
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Build a source bundle ZIP while excluding credentials, private keys,
|
||||
# local state, generated artifacts and other likely secrets.
|
||||
#
|
||||
# Usage:
|
||||
# ./bundle.sh
|
||||
# ./bundle.sh path/to/output.zip
|
||||
#
|
||||
# Run from anywhere inside the project; the script resolves its own directory.
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
||||
case "$OUT" in
|
||||
/*) ;;
|
||||
*) OUT="$ROOT/$OUT" ;;
|
||||
esac
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
echo "ERROR: 'zip' is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
LIST="$TMP/files.txt"
|
||||
SAFE_LIST="$TMP/safe-files.txt"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Paths / filenames that must never be bundled.
|
||||
is_denied_path() {
|
||||
local p="/$1"
|
||||
|
||||
case "$p" in
|
||||
*/.git/*|*/.git|\
|
||||
*/.gradle/*|*/.gradle|\
|
||||
*/.gradle-home/*|*/.gradle-home|\
|
||||
*/.idea/*|*/.idea|\
|
||||
*/.vscode/*|*/.vscode|\
|
||||
*/node_modules/*|*/node_modules|\
|
||||
*/target/*|*/target|\
|
||||
*/build/*|*/build|\
|
||||
*/out/*|*/out|\
|
||||
*/bin/*|*/bin|\
|
||||
*/logs/*|*/logs|\
|
||||
*/data/*|*/data|\
|
||||
*/test-ledger/*|*/test-ledger|\
|
||||
*/.anchor/*|*/.anchor|\
|
||||
*/.yarn/*|*/.yarn|\
|
||||
*/.vendor/*|*/.vendor|\
|
||||
*/.agents/*|*/.agents|\
|
||||
*/.codex/*|*/.codex|\
|
||||
*/.claude/*|*/.claude|\
|
||||
*/deploy/backup/archive/*|\
|
||||
*/scripts/*/runs/*|\
|
||||
*/scripts/*/keypairs/*|\
|
||||
*/keys/*|\
|
||||
*/.git-local-backup/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
local base="${p##*/}"
|
||||
local lower
|
||||
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$lower" in
|
||||
.env|.env.*|\
|
||||
.debug-token|\
|
||||
.npmrc|.pypirc|.netrc|\
|
||||
credentials|credentials.*|\
|
||||
secrets|secrets.*|\
|
||||
secret|secret.*|\
|
||||
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||
*keypair*.json|\
|
||||
service-account*.json|\
|
||||
firebase-adminsdk*.json|\
|
||||
google-services.json|\
|
||||
validator.log)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$lower" in
|
||||
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||
.ds_store)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||
else
|
||||
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||
fi
|
||||
|
||||
# Convert to project-relative paths and enforce hard deny rules.
|
||||
: > "$LIST"
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ "$f" = /* ]]; then
|
||||
rel="${f#"$ROOT"/}"
|
||||
else
|
||||
rel="$f"
|
||||
fi
|
||||
|
||||
[[ "$rel" == "$OUT" ]] && continue
|
||||
[[ -z "$rel" ]] && continue
|
||||
|
||||
if is_denied_path "$rel"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$LIST"
|
||||
done < "$TMP/files.z"
|
||||
|
||||
sort -u "$LIST" -o "$LIST"
|
||||
|
||||
# Content scan: fail closed on common credential/private-key patterns.
|
||||
# We scan only text-ish files; grep -I skips binary data.
|
||||
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||
|
||||
: > "$SAFE_LIST"
|
||||
found_secret=0
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -f "$ROOT/$rel" ]] || continue
|
||||
|
||||
# Files that contain examples/templates can legitimately mention secret keys
|
||||
# with placeholders. They are scanned too, but placeholder-looking values
|
||||
# are less likely to match the regex above.
|
||||
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||
echo "BLOCKED: possible secret in $rel" >&2
|
||||
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||
| head -n 3 >&2 || true
|
||||
found_secret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||
done < "$LIST"
|
||||
|
||||
if (( found_secret != 0 )); then
|
||||
echo >&2
|
||||
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||
echo "ERROR: no files left to bundle." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
rm -f -- "$OUT"
|
||||
|
||||
(
|
||||
cd "$ROOT"
|
||||
zip -q -9 "$OUT" -@ < "$SAFE_LIST"
|
||||
)
|
||||
|
||||
echo "Created: $OUT"
|
||||
echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')"
|
||||
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||
@@ -13,6 +13,9 @@
|
||||
- `user_access_servers_current`
|
||||
- `solana_user_pda_history`
|
||||
- источник истины по пользовательским PDA: `solana_user_pda_current`.
|
||||
- в `solana_user_pda_current` хранятся оба варианта логина:
|
||||
- `login` — display-логин из PDA;
|
||||
- `normalized_login` — канонический lower-case для runtime lookup и части FK;
|
||||
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
|
||||
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
||||
|
||||
@@ -56,7 +59,7 @@ psql \
|
||||
Скрипт:
|
||||
|
||||
- создаёт таблицу версии схемы `db_schema_version`;
|
||||
- ставит `schema_version = 2`;
|
||||
- ставит актуальный `schema_version`;
|
||||
- создаёт таблицы sync-модуля Solana users;
|
||||
- создаёт server runtime tables;
|
||||
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
||||
|
||||
@@ -51,10 +51,14 @@
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
|
||||
| `ListUserParams` | `10_User_Params_API.md` | список параметров пользователя |
|
||||
| `UpsertUserSetting` | `13_User_Settings_API.md` | запись пользовательской настройки |
|
||||
| `GetUserSetting` | `13_User_Settings_API.md` | чтение одной пользовательской настройки |
|
||||
| `ListUserSettings` | `13_User_Settings_API.md` | список пользовательских настроек |
|
||||
| `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья |
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
@@ -63,6 +67,8 @@
|
||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
|
||||
| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору |
|
||||
| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных |
|
||||
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||
@@ -71,7 +77,6 @@
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
|
||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
Важно:
|
||||
|
||||
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# API пользовательских настроек
|
||||
|
||||
Этот раздел описывает `user_settings` - отдельное хранилище пользовательских настроек, не связанное с `users_params` и не связанное с legacy DM-таблицами.
|
||||
|
||||
## 1. Назначение
|
||||
|
||||
`user_settings` хранит технические пользовательские настройки, которые должны синхронизироваться между максимум двумя access/sync-серверами пользователя.
|
||||
|
||||
Основной текущий кейс:
|
||||
|
||||
- `setting_type = 1` - курсор прочитанности канала;
|
||||
- `setting_key = ownerBlockchainName/channelName`;
|
||||
- `value_num = number of messages already seen in channel`;
|
||||
- `value_text = ''`.
|
||||
|
||||
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
|
||||
|
||||
## 2. Структура записи
|
||||
|
||||
- `login` - логин владельца настройки;
|
||||
- `setting_type` - числовой код типа настройки;
|
||||
- `setting_key` - строковый ключ настройки;
|
||||
- `time_ms` - время установки значения в миллисекундах;
|
||||
- `value_text` - строковое значение;
|
||||
- `value_num` - числовое значение;
|
||||
- `client_key` - публичный Ed25519 ключ клиента в Base64;
|
||||
- `signature` - Ed25519 подпись preimage в Base64;
|
||||
- `synced` - была ли настройка успешно доставлена на второй сервер.
|
||||
|
||||
Уникальность: `(login, setting_type, setting_key)`.
|
||||
Обновление: только если `time_ms` новее текущего значения.
|
||||
|
||||
## 3. Формат подписи
|
||||
|
||||
Подписывается строка:
|
||||
|
||||
`SHiNe/UserSettings:|login|setting_type|setting_key|time_ms|value_text|value_num`
|
||||
|
||||
Где внутри полей используется экранирование `\` и `|`.
|
||||
|
||||
Подпись создаётся клиентским `client_key`.
|
||||
|
||||
## 4. Операции
|
||||
|
||||
### `UpsertUserSetting`
|
||||
|
||||
Записывает или обновляет настройку пользователя.
|
||||
|
||||
Если запрос пришёл от клиента, сервер:
|
||||
|
||||
- сохраняет запись локально;
|
||||
- пытается сразу отправить её на доступный sync-сервер;
|
||||
- если отправка успешна, помечает запись как `synced=true`;
|
||||
- если нет, оставляет `synced=false`.
|
||||
|
||||
Если запрос пришёл по синхронизации между серверами, используется `sync_delivery=true`, и повторной пересылки дальше не делается.
|
||||
|
||||
### `GetUserSetting`
|
||||
|
||||
Чтение одной настройки по `(login, setting_type, setting_key)`.
|
||||
|
||||
### `ListUserSettings`
|
||||
|
||||
Список всех настроек пользователя.
|
||||
|
||||
### `UserSettingsSyncBatch`
|
||||
|
||||
Внутренний межсерверный batch-эндпоинт.
|
||||
|
||||
- отдаёт настройки, новые относительно курсора;
|
||||
- используется для bootstrap и догрузки после восстановления;
|
||||
- применяется только для пользователей, чей сервер есть в `access_servers`.
|
||||
|
||||
### `MarkAllUserSettingsUnsynced`
|
||||
|
||||
Внутренний служебный запрос.
|
||||
|
||||
- помечает все настройки пользователя или все настройки сразу как `synced=false`;
|
||||
- нужен после добавления нового sync-сервера или при потере локальной БД.
|
||||
|
||||
## 5. Синхронизация
|
||||
|
||||
Синхронизация настроек работает отдельно от DM.
|
||||
|
||||
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
|
||||
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
|
||||
- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору;
|
||||
- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля.
|
||||
|
||||
## 6. Текущий UI-кейс
|
||||
|
||||
UI при открытии канала отправляет `UpsertUserSetting` с:
|
||||
|
||||
- `setting_type = 1`;
|
||||
- `setting_key = ownerBlockchainName/channelName`;
|
||||
- `value_num = количество уже просмотренных сообщений в канале`.
|
||||
|
||||
Это значение используется сервером для расчёта unread в списке каналов и в канале.
|
||||
@@ -0,0 +1,81 @@
|
||||
# API для разработчиков: уведомления
|
||||
|
||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
||||
|
||||
Текущая операция:
|
||||
|
||||
- `GetNotifications`
|
||||
|
||||
## 1. `GetNotifications`
|
||||
|
||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
||||
|
||||
Возвращаются две отдельные ленты:
|
||||
|
||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
@@ -19,6 +19,10 @@
|
||||
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||
|
||||
Важно: в текущей архитектуре у пользователя одновременно может быть не более
|
||||
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
|
||||
`synced` и пользовательских курсоров.
|
||||
|
||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
||||
@@ -39,6 +43,13 @@
|
||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||
- Дедупликация по глобальному номеру блока и хэшу.
|
||||
|
||||
### 3.3 Пользовательские настройки
|
||||
|
||||
- Отдельная таблица `user_settings`.
|
||||
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
|
||||
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
|
||||
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
|
||||
|
||||
## 4. Текущая реализованная схема
|
||||
|
||||
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
|
||||
|
||||
@@ -334,8 +334,6 @@
|
||||
|
||||
### 8.2. Новые методы, которые нужны
|
||||
|
||||
Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
|
||||
|
||||
## 9. Правила валидации и применения
|
||||
|
||||
### 9.1. Общее правило по ревизиям
|
||||
@@ -653,7 +651,6 @@ UI-следствие для клиента:
|
||||
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
||||
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
||||
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
||||
- legacy `SendDirectMessage` должен быть отключён;
|
||||
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
||||
|
||||
## 14. Что в v1 пока не входит
|
||||
|
||||
@@ -323,6 +323,7 @@ Append-only журнал всех просмотренных транзакци
|
||||
|
||||
- `pda_address TEXT PRIMARY KEY`
|
||||
- `login TEXT NOT NULL`
|
||||
- `normalized_login TEXT NOT NULL`
|
||||
- `record_number INTEGER NOT NULL`
|
||||
- `slot INTEGER NOT NULL`
|
||||
- `last_tx_signature TEXT NOT NULL`
|
||||
@@ -349,9 +350,16 @@ Append-only журнал всех просмотренных транзакци
|
||||
Индексы:
|
||||
|
||||
- уникальный индекс на `login`
|
||||
- уникальный индекс на `normalized_login`
|
||||
- индекс на `slot`
|
||||
- индекс на `last_tx_signature`
|
||||
|
||||
Правило использования:
|
||||
|
||||
- `login` хранит display-логин ровно в том регистре, как он записан в PDA;
|
||||
- `normalized_login` хранит канонический lower-case логин;
|
||||
- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case.
|
||||
|
||||
### 4. `solana_user_pda_history`
|
||||
|
||||
Append-only история всех версий пользовательских PDA.
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
/>
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
@@ -12,13 +12,13 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
window.__SHINE_BUILD_HASH__ = '20260823123601';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<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'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
+139
-478
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
navigate,
|
||||
getRoute,
|
||||
parseRouteFromPath,
|
||||
PRE_AUTH_PAGES,
|
||||
getSwipeNavigationTarget,
|
||||
syncTrackedRouteHistory,
|
||||
rememberToolbarRoute,
|
||||
resetRememberedToolbarRoutes,
|
||||
resolveToolbarActive,
|
||||
} from './router.js';
|
||||
import { renderToolbar } from './components/toolbar.js';
|
||||
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
|
||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
@@ -85,17 +81,17 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202607152145';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -173,14 +169,6 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50;
|
||||
const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
|
||||
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
||||
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
|
||||
const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']);
|
||||
const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72;
|
||||
const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56;
|
||||
const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35;
|
||||
const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14;
|
||||
const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32;
|
||||
const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18;
|
||||
const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260;
|
||||
|
||||
let currentCleanup = null;
|
||||
let pingIntervalId = null;
|
||||
@@ -202,10 +190,16 @@ let hiddenDmAudioUnlocked = false;
|
||||
let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
let currentMountState = null;
|
||||
let activeSwipePreview = null;
|
||||
const keepAliveEntries = new Map();
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
'messages-list',
|
||||
'chat-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-thread-view',
|
||||
'notifications-view',
|
||||
]);
|
||||
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
@@ -237,6 +231,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
|
||||
setShellMetricVar('--keyboard-offset', valuePx);
|
||||
}
|
||||
|
||||
let stableViewportHeightPx = Math.max(
|
||||
1,
|
||||
Math.round(window.innerHeight || document.documentElement?.clientHeight || window.visualViewport?.height || 0),
|
||||
);
|
||||
|
||||
function isTextEntryFocused() {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLTextAreaElement
|
||||
|| (active instanceof HTMLInputElement && !['button', 'checkbox', 'radio', 'range', 'file', 'submit', 'reset'].includes(active.type));
|
||||
}
|
||||
|
||||
function syncViewportMetrics() {
|
||||
if (!appShellEl) return;
|
||||
const viewport = window.visualViewport || null;
|
||||
const currentLayoutHeightPx = Math.max(
|
||||
1,
|
||||
Math.round(window.innerHeight || document.documentElement?.clientHeight || viewport?.height || 0),
|
||||
);
|
||||
const widthPx = Math.max(1, Math.round(window.innerWidth || viewport?.width || 0));
|
||||
const offsetLeftPx = Math.max(0, Math.round(viewport?.offsetLeft || 0));
|
||||
const textEntryFocused = isTextEntryFocused();
|
||||
|
||||
// Android Chrome/Firefox могут уменьшать и visualViewport, и innerHeight.
|
||||
// Поэтому высоту экрана до открытия клавиатуры запоминаем отдельно и во
|
||||
// время ввода НЕ переписываем ею app-shell. Иначе toolbar тоже поднимется.
|
||||
if (!textEntryFocused) {
|
||||
stableViewportHeightPx = Math.max(stableViewportHeightPx, currentLayoutHeightPx);
|
||||
// После поворота/реального resize разрешаем уменьшить базу, но только
|
||||
// когда никакое текстовое поле не держит экранную клавиатуру.
|
||||
if (Math.abs(stableViewportHeightPx - currentLayoutHeightPx) > 220) {
|
||||
stableViewportHeightPx = currentLayoutHeightPx;
|
||||
}
|
||||
} else if (currentLayoutHeightPx > stableViewportHeightPx) {
|
||||
stableViewportHeightPx = currentLayoutHeightPx;
|
||||
}
|
||||
|
||||
const visualBottomPx = viewport
|
||||
? Math.round((viewport.offsetTop || 0) + viewport.height)
|
||||
: currentLayoutHeightPx;
|
||||
const rawKeyboardOffsetPx = Math.max(
|
||||
0,
|
||||
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
|
||||
);
|
||||
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
|
||||
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
|
||||
? rawKeyboardOffsetPx
|
||||
: 0;
|
||||
|
||||
setShellMetricVar('--app-viewport-width', widthPx);
|
||||
setShellMetricVar('--app-viewport-height', keyboardOffsetPx > 0 ? stableViewportHeightPx : currentLayoutHeightPx);
|
||||
setShellMetricVar('--app-viewport-offset-top', 0);
|
||||
setShellMetricVar('--app-viewport-offset-left', offsetLeftPx);
|
||||
setKeyboardOffsetPx(keyboardOffsetPx);
|
||||
appShellEl.classList.toggle('keyboard-open', keyboardOffsetPx > 0);
|
||||
}
|
||||
|
||||
function attachSlotHeightObserver(slotEl, cssVarName) {
|
||||
if (!slotEl || typeof ResizeObserver !== 'function') return null;
|
||||
const sync = () => {
|
||||
@@ -253,6 +303,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
|
||||
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
|
||||
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
|
||||
|
||||
syncViewportMetrics();
|
||||
window.visualViewport?.addEventListener('resize', syncViewportMetrics);
|
||||
window.visualViewport?.addEventListener('scroll', syncViewportMetrics);
|
||||
window.addEventListener('resize', syncViewportMetrics);
|
||||
document.addEventListener('focusin', () => {
|
||||
requestAnimationFrame(syncViewportMetrics);
|
||||
// Samsung One UI / Firefox can finish the OSK viewport transition several
|
||||
// frames after focus. Re-sample through the animation instead of trusting
|
||||
// the first resize event.
|
||||
window.setTimeout(syncViewportMetrics, 80);
|
||||
window.setTimeout(syncViewportMetrics, 180);
|
||||
window.setTimeout(syncViewportMetrics, 320);
|
||||
});
|
||||
document.addEventListener('focusout', () => {
|
||||
window.setTimeout(syncViewportMetrics, 80);
|
||||
window.setTimeout(syncViewportMetrics, 220);
|
||||
});
|
||||
|
||||
// Optional on-device viewport diagnostics: append ?keyboard-debug=1 to the URL.
|
||||
if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
const debugEl = document.createElement('pre');
|
||||
debugEl.id = 'keyboard-viewport-debug';
|
||||
Object.assign(debugEl.style, {
|
||||
position: 'fixed', top: '4px', right: '4px', zIndex: '999999', margin: '0',
|
||||
maxWidth: '94vw', padding: '6px 8px', fontSize: '10px', lineHeight: '1.25',
|
||||
color: '#fff', background: 'rgba(0,0,0,.82)', pointerEvents: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
});
|
||||
document.body.append(debugEl);
|
||||
const syncDebug = () => {
|
||||
const vv = window.visualViewport;
|
||||
debugEl.textContent = [
|
||||
`innerHeight=${window.innerHeight}`,
|
||||
`clientHeight=${document.documentElement.clientHeight}`,
|
||||
`vv.height=${vv ? Math.round(vv.height) : 'n/a'}`,
|
||||
`vv.offsetTop=${vv ? Math.round(vv.offsetTop) : 'n/a'}`,
|
||||
`stable=${stableViewportHeightPx}`,
|
||||
`keyboard=${getComputedStyle(appShellEl).getPropertyValue('--keyboard-offset').trim()}`,
|
||||
`focused=${isTextEntryFocused()}`,
|
||||
].join(' | ');
|
||||
};
|
||||
window.visualViewport?.addEventListener('resize', syncDebug);
|
||||
window.visualViewport?.addEventListener('scroll', syncDebug);
|
||||
window.addEventListener('resize', syncDebug);
|
||||
document.addEventListener('focusin', () => window.setTimeout(syncDebug, 330));
|
||||
document.addEventListener('focusout', () => window.setTimeout(syncDebug, 230));
|
||||
syncDebug();
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
@@ -319,403 +418,11 @@ function createChromeController(showAppChrome) {
|
||||
};
|
||||
}
|
||||
|
||||
function destroyMountState(entry) {
|
||||
if (!entry) return;
|
||||
if (entry.destroyed) return;
|
||||
entry.destroyed = true;
|
||||
try {
|
||||
if (typeof entry.cleanup === 'function') {
|
||||
entry.cleanup();
|
||||
}
|
||||
} finally {
|
||||
entry.chrome?.dispose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeepAliveEntries() {
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
keepAliveEntries.forEach((entry) => destroyMountState(entry));
|
||||
keepAliveEntries.clear();
|
||||
resetRememberedToolbarRoutes();
|
||||
currentMountState = null;
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
function detachMountedScreen(entry) {
|
||||
if (!entry) return;
|
||||
entry.chrome?.suspend?.();
|
||||
if (entry.screen?.parentNode === screenEl) {
|
||||
screenEl.removeChild(entry.screen);
|
||||
} else {
|
||||
screenEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function mountExistingEntry(entry, { showAppChrome, pageId }) {
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
screenEl.innerHTML = '';
|
||||
screenEl.append(entry.screen);
|
||||
entry.chrome?.resume?.();
|
||||
currentMountState = entry;
|
||||
currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null;
|
||||
currentChromeCleanup = () => entry.chrome?.dispose?.();
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
}
|
||||
|
||||
function cloneScreenForSwipe(screen) {
|
||||
const clone = screen?.cloneNode?.(true);
|
||||
if (!(clone instanceof Node)) return null;
|
||||
return clone;
|
||||
}
|
||||
|
||||
function sanitizeSwipeClone(node) {
|
||||
if (!(node instanceof Element)) return;
|
||||
node.removeAttribute('id');
|
||||
node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
||||
}
|
||||
|
||||
function cloneSlotChildForSwipe(slotEl) {
|
||||
const child = slotEl?.firstElementChild;
|
||||
if (!(child instanceof Node)) return null;
|
||||
const clone = child.cloneNode(true);
|
||||
if (clone instanceof Element) sanitizeSwipeClone(clone);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function createSwipeFrameSlot(className, contentNode = null) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = className;
|
||||
if (contentNode instanceof Node) {
|
||||
slot.append(contentNode);
|
||||
slot.hidden = false;
|
||||
} else {
|
||||
slot.hidden = true;
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
function buildSwipePane({
|
||||
topbarNode = null,
|
||||
screenNode = null,
|
||||
composerNode = null,
|
||||
screenClassName = '',
|
||||
screenScrollTop = 0,
|
||||
}) {
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'screen-swipe-pane';
|
||||
|
||||
const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode);
|
||||
const screenSlot = document.createElement('main');
|
||||
screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`;
|
||||
if (screenNode instanceof Node) {
|
||||
screenSlot.append(screenNode);
|
||||
}
|
||||
const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode);
|
||||
|
||||
pane.append(topbarSlot, screenSlot, composerSlot);
|
||||
requestAnimationFrame(() => {
|
||||
screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0));
|
||||
});
|
||||
return pane;
|
||||
}
|
||||
|
||||
function createSwipePreviewTarget(targetPath) {
|
||||
const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`);
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const rootPageId = resolveToolbarActive(pageId);
|
||||
const cachedEntry = keepAliveEntries.get(rootPageId);
|
||||
if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) {
|
||||
return {
|
||||
screen: cloneScreenForSwipe(cachedEntry.screen),
|
||||
cleanup: null,
|
||||
};
|
||||
}
|
||||
|
||||
let previewTopbarNode = null;
|
||||
let previewComposerNode = null;
|
||||
const chrome = {
|
||||
setTopbar(node = null) {
|
||||
previewTopbarNode = node instanceof Node ? node : null;
|
||||
},
|
||||
setComposer(node = null) {
|
||||
previewComposerNode = node instanceof Node ? node : null;
|
||||
},
|
||||
clear() {
|
||||
previewTopbarNode = null;
|
||||
previewComposerNode = null;
|
||||
},
|
||||
suspend() {},
|
||||
resume() {},
|
||||
dispose() {},
|
||||
};
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
chrome.dispose();
|
||||
throw new Error('Swipe preview render returned invalid node');
|
||||
}
|
||||
const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
return {
|
||||
screen,
|
||||
topbarNode: previewTopbarNode,
|
||||
composerNode: previewComposerNode,
|
||||
cleanup: () => {
|
||||
try {
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
} finally {
|
||||
chrome.dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applySwipePreviewOffset(session, revealPx) {
|
||||
if (!session) return;
|
||||
const width = Math.max(1, session.width);
|
||||
const clamped = Math.max(0, Math.min(width, revealPx));
|
||||
session.revealPx = clamped;
|
||||
|
||||
const currentX = session.direction === 'left' ? -clamped : clamped;
|
||||
const targetX = session.direction === 'left'
|
||||
? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX
|
||||
: -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX;
|
||||
const dividerX = session.direction === 'left'
|
||||
? width - clamped
|
||||
: clamped;
|
||||
|
||||
session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`;
|
||||
session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`;
|
||||
session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`;
|
||||
|
||||
const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24));
|
||||
session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3));
|
||||
}
|
||||
|
||||
function teardownSwipePreview({ cancelOnly = false } = {}) {
|
||||
const session = activeSwipePreview;
|
||||
if (!session) return;
|
||||
activeSwipePreview = null;
|
||||
|
||||
appShellEl?.classList.remove('app-shell--swiping');
|
||||
topbarEl?.classList.remove('topbar-slot--swipe-hidden');
|
||||
screenEl.classList.remove('screen-content--swipe-hidden');
|
||||
composerEl?.classList.remove('composer-slot--swipe-hidden');
|
||||
session.overlay.remove();
|
||||
if (typeof session.targetCleanup === 'function') {
|
||||
session.targetCleanup();
|
||||
}
|
||||
if (!cancelOnly) {
|
||||
session.onComplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) {
|
||||
const width = Math.max(1, session.width);
|
||||
const currentReveal = Number(session.revealPx || 0);
|
||||
const remaining = Math.abs(revealPx - currentReveal);
|
||||
const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS)));
|
||||
|
||||
[session.currentPane, session.targetPane, session.divider].forEach((node) => {
|
||||
node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
});
|
||||
session.overlay.style.transition = `opacity ${duration}ms ease`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
applySwipePreviewOffset(session, revealPx);
|
||||
if (!complete) {
|
||||
session.overlay.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
teardownSwipePreview({ cancelOnly: !complete });
|
||||
}, duration + 24);
|
||||
}
|
||||
|
||||
function beginSwipePreview(direction, targetPath) {
|
||||
if (!currentMountState?.screen || activeSwipePreview) return null;
|
||||
const currentTopbarClone = cloneSlotChildForSwipe(topbarEl);
|
||||
const currentScreenClone = cloneScreenForSwipe(currentMountState.screen);
|
||||
const currentComposerClone = cloneSlotChildForSwipe(composerEl);
|
||||
if (!(currentScreenClone instanceof Node)) return null;
|
||||
if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone);
|
||||
if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone);
|
||||
if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone);
|
||||
|
||||
const targetPreview = createSwipePreviewTarget(targetPath);
|
||||
if (!(targetPreview?.screen instanceof Node)) {
|
||||
targetPreview?.cleanup?.();
|
||||
return null;
|
||||
}
|
||||
if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode);
|
||||
if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen);
|
||||
if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'screen-swipe-overlay';
|
||||
|
||||
const currentPane = buildSwipePane({
|
||||
topbarNode: currentTopbarClone,
|
||||
screenNode: currentScreenClone,
|
||||
composerNode: currentComposerClone,
|
||||
screenClassName: screenEl.className,
|
||||
screenScrollTop: screenEl.scrollTop,
|
||||
});
|
||||
currentPane.classList.add('screen-swipe-pane--current');
|
||||
|
||||
const targetPane = buildSwipePane({
|
||||
topbarNode: targetPreview.topbarNode || null,
|
||||
screenNode: targetPreview.screen,
|
||||
composerNode: targetPreview.composerNode || null,
|
||||
screenClassName: screenEl.className,
|
||||
screenScrollTop: 0,
|
||||
});
|
||||
targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`);
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'screen-swipe-divider';
|
||||
|
||||
overlay.append(currentPane, targetPane, divider);
|
||||
appShellEl.append(overlay);
|
||||
appShellEl?.classList.add('app-shell--swiping');
|
||||
topbarEl?.classList.add('topbar-slot--swipe-hidden');
|
||||
screenEl.classList.add('screen-content--swipe-hidden');
|
||||
composerEl?.classList.add('composer-slot--swipe-hidden');
|
||||
|
||||
const session = {
|
||||
direction,
|
||||
targetPath,
|
||||
width: screenEl.clientWidth || 1,
|
||||
overlay,
|
||||
currentPane,
|
||||
targetPane,
|
||||
divider,
|
||||
targetCleanup: targetPreview.cleanup || null,
|
||||
revealPx: 0,
|
||||
onComplete: () => navigate(targetPath),
|
||||
};
|
||||
activeSwipePreview = session;
|
||||
applySwipePreviewOffset(session, 0);
|
||||
return session;
|
||||
}
|
||||
|
||||
function installHorizontalTabSwipe() {
|
||||
if (!screenEl) return;
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchActive = false;
|
||||
let touchBlocked = false;
|
||||
let swipeLocked = false;
|
||||
let swipeDirection = '';
|
||||
let swipeTargetPath = '';
|
||||
let swipeSession = null;
|
||||
|
||||
const reset = () => {
|
||||
touchActive = false;
|
||||
touchBlocked = false;
|
||||
swipeLocked = false;
|
||||
swipeDirection = '';
|
||||
swipeTargetPath = '';
|
||||
swipeSession = null;
|
||||
touchStartX = 0;
|
||||
touchStartY = 0;
|
||||
};
|
||||
|
||||
screenEl.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length !== 1) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]'));
|
||||
touchActive = !touchBlocked;
|
||||
touchStartX = Number(event.touches[0]?.clientX || 0);
|
||||
touchStartY = Number(event.touches[0]?.clientY || 0);
|
||||
}, { passive: true });
|
||||
|
||||
screenEl.addEventListener('touchmove', (event) => {
|
||||
if (!touchActive || touchBlocked) return;
|
||||
const touch = event.touches?.[0];
|
||||
const deltaX = Number(touch?.clientX || 0) - touchStartX;
|
||||
const deltaY = Number(touch?.clientY || 0) - touchStartY;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
|
||||
if (!swipeLocked) {
|
||||
if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return;
|
||||
if (absX <= absY * 1.05) {
|
||||
touchBlocked = true;
|
||||
return;
|
||||
}
|
||||
const currentPageId = getRoute().pageId || '';
|
||||
swipeDirection = deltaX < 0 ? 'left' : 'right';
|
||||
swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection);
|
||||
if (!swipeTargetPath) {
|
||||
touchBlocked = true;
|
||||
return;
|
||||
}
|
||||
swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath);
|
||||
if (!swipeSession) {
|
||||
touchBlocked = true;
|
||||
return;
|
||||
}
|
||||
swipeLocked = true;
|
||||
}
|
||||
|
||||
if (!swipeLocked || !swipeSession) return;
|
||||
event.preventDefault();
|
||||
|
||||
const revealPx = swipeDirection === 'left'
|
||||
? Math.max(0, -deltaX)
|
||||
: Math.max(0, deltaX);
|
||||
applySwipePreviewOffset(swipeSession, revealPx);
|
||||
}, { passive: false });
|
||||
|
||||
screenEl.addEventListener('touchcancel', reset, { passive: true });
|
||||
|
||||
screenEl.addEventListener('touchend', (event) => {
|
||||
if (!touchActive || touchBlocked) {
|
||||
if (swipeSession) {
|
||||
animateSwipePreviewTo(swipeSession, 0, { complete: false });
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches?.[0];
|
||||
const endX = Number(touch?.clientX || 0);
|
||||
const endY = Number(touch?.clientY || 0);
|
||||
const deltaX = endX - touchStartX;
|
||||
const deltaY = endY - touchStartY;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
const session = swipeSession;
|
||||
const wasLocked = swipeLocked;
|
||||
reset();
|
||||
|
||||
if (wasLocked && session) {
|
||||
event.preventDefault();
|
||||
const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width);
|
||||
const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO;
|
||||
animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit });
|
||||
return;
|
||||
}
|
||||
|
||||
if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return;
|
||||
if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return;
|
||||
if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return;
|
||||
|
||||
const currentPageId = getRoute().pageId || '';
|
||||
const direction = deltaX < 0 ? 'left' : 'right';
|
||||
const target = getSwipeNavigationTarget(currentPageId, direction);
|
||||
if (!target) return;
|
||||
navigate(target);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -1396,7 +1103,6 @@ function renderPageFailureFallback(pageId, error) {
|
||||
});
|
||||
|
||||
screenEl.innerHTML = '';
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -1432,8 +1138,15 @@ function renderPageFailureFallback(pageId, error) {
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function attachPageScrollToBottom(pageId, screen) {
|
||||
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
|
||||
|
||||
return attachScrollToBottomButton({
|
||||
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
|
||||
});
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
@@ -1450,56 +1163,13 @@ function renderApp() {
|
||||
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
const rootPageId = resolveToolbarActive(pageId);
|
||||
const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId);
|
||||
const currentRoutePath = String(window.location.pathname || '/');
|
||||
|
||||
rememberToolbarRoute(pageId);
|
||||
|
||||
if (currentMountState) {
|
||||
const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId;
|
||||
if (shouldPreserveCurrent) {
|
||||
currentMountState.routePath = currentMountState.routePath || currentRoutePath;
|
||||
keepAliveEntries.set(currentMountState.rootPageId, currentMountState);
|
||||
detachMountedScreen(currentMountState);
|
||||
currentMountState = null;
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
} else {
|
||||
destroyMountState(currentMountState);
|
||||
if (currentMountState.keepAlive) {
|
||||
keepAliveEntries.delete(currentMountState.rootPageId);
|
||||
}
|
||||
currentMountState = null;
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
} else {
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
|
||||
const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null;
|
||||
if (cachedEntry && cachedEntry.routePath === currentRoutePath) {
|
||||
mountExistingEntry(cachedEntry, { showAppChrome, pageId });
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedEntry) {
|
||||
destroyMountState(cachedEntry);
|
||||
keepAliveEntries.delete(rootPageId);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1512,20 +1182,12 @@ function renderApp() {
|
||||
}
|
||||
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
currentMountState = {
|
||||
pageId,
|
||||
rootPageId,
|
||||
keepAlive: keepAliveEligible,
|
||||
routePath: currentRoutePath,
|
||||
screen,
|
||||
cleanup: currentCleanup,
|
||||
chrome,
|
||||
destroyed: false,
|
||||
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
||||
currentCleanup = () => {
|
||||
pageCleanup?.();
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
if (keepAliveEligible) {
|
||||
keepAliveEntries.set(rootPageId, currentMountState);
|
||||
}
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
@@ -2008,7 +1670,6 @@ async function init() {
|
||||
})();
|
||||
|
||||
window.addEventListener('popstate', renderApp);
|
||||
installHorizontalTabSwipe();
|
||||
document.addEventListener('pointerdown', () => {
|
||||
void unlockHiddenDmAudio();
|
||||
}, { passive: true });
|
||||
|
||||
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
|
||||
onSelect,
|
||||
selectedTxIds = [],
|
||||
historyOnly = false,
|
||||
persistToHistory = true,
|
||||
allowHistorySelection = true,
|
||||
allowExistingTxInput = true,
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
initialSha256 = '',
|
||||
initialName = '',
|
||||
fixedFile = false,
|
||||
autoOpenFileDialog = true,
|
||||
shineType = '',
|
||||
extraUploadTags = [],
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
|
||||
let selectedPreviewPriceInfo = null;
|
||||
let priceInfo = null;
|
||||
let balanceInfo = null;
|
||||
let autoOpenedFileDialog = false;
|
||||
let autoOpenedFileDialogOnce = false;
|
||||
const isAvatarMode = String(mode || '') === 'avatar';
|
||||
if (isAvatarMode && !String(uploadTransport || '').trim()) {
|
||||
selectedUploadTransport = 'turbo';
|
||||
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
|
||||
const historyPurposeMode = String(historyPurpose || '').trim();
|
||||
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||
const effectiveDialogTitle = String(dialogTitle || '').trim();
|
||||
const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim();
|
||||
const forcedShineType = String(shineType || '').trim();
|
||||
const normalizedExtraUploadTags = Array.isArray(extraUploadTags)
|
||||
? extraUploadTags.filter((item) => item?.name && item?.value)
|
||||
: [];
|
||||
if (initialFile instanceof File) {
|
||||
selectedFile = initialFile;
|
||||
if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isTurboUpload() {
|
||||
return selectedUploadTransport === 'turbo';
|
||||
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
});
|
||||
const item = persistToHistory
|
||||
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
})
|
||||
: {
|
||||
...attachment,
|
||||
...normalizeAttachment(attachment),
|
||||
};
|
||||
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
||||
close(resolve, item);
|
||||
}
|
||||
@@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({
|
||||
|
||||
const showUpload = async () => {
|
||||
const turboMode = isTurboUpload();
|
||||
const titleText = effectiveDialogTitle
|
||||
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|
||||
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
||||
const canShowHistory = allowHistorySelection;
|
||||
const canShowExisting = allowExistingTxInput;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
|
||||
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
@@ -611,8 +643,8 @@ export function openArweaveAttachmentManager({
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
`}
|
||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -622,7 +654,7 @@ export function openArweaveAttachmentManager({
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
@@ -818,27 +850,30 @@ export function openArweaveAttachmentManager({
|
||||
storagePwd: cleanStoragePwd,
|
||||
keySource: selectedTurboKeySource,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
})
|
||||
: await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
jwk: selectedWallet()?.jwk,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
});
|
||||
finish(resolve, {
|
||||
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
||||
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
|
||||
size: selectedFile.size,
|
||||
sha256: selectedSha256,
|
||||
ar: uploaded.id,
|
||||
uploadTransport: turboMode ? 'turbo' : 'arweave',
|
||||
preview: previewUpload?.id && selectedPreviewSha256
|
||||
? {
|
||||
ar: previewUpload.id,
|
||||
@@ -854,8 +889,19 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
});
|
||||
|
||||
if (!autoOpenedFileDialog) {
|
||||
autoOpenedFileDialog = true;
|
||||
if (selectedFile && selectedSha256) {
|
||||
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
|
||||
previewOptionEl.hidden = false;
|
||||
}
|
||||
if (turboMode) {
|
||||
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||
} else {
|
||||
await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixedFile && autoOpenFileDialog && !autoOpenedFileDialogOnce) {
|
||||
autoOpenedFileDialogOnce = true;
|
||||
window.setTimeout(() => fileEl?.click(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
if (sizeClass) classes.add(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||
if (glow) classes.add('avatar-glow');
|
||||
wrap.className = Array.from(classes).join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.className = 'avatar-photo';
|
||||
img.alt = String(alt || 'Аватар');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
wrap.append(img);
|
||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
return renderAvatar({
|
||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
alt: 'Аватар',
|
||||
glow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-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';
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
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);
|
||||
}
|
||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
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';
|
||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function createOverflowDots({ className = '' } = {}) {
|
||||
const dots = document.createElement('span');
|
||||
const extra = String(className || '').trim();
|
||||
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
||||
dots.setAttribute('aria-hidden', 'true');
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
dots.append(document.createElement('i'));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function attachScrollToBottomButton({
|
||||
scrollContainer,
|
||||
mountTarget = document.querySelector('.app-shell'),
|
||||
thresholdPx = 160,
|
||||
title = 'Вниз',
|
||||
} = {}) {
|
||||
const target = resolveElement(mountTarget);
|
||||
if (!(target instanceof Element)) {
|
||||
return {
|
||||
button: null,
|
||||
refresh() {},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'scroll-to-bottom-btn';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||
button.tabIndex = -1;
|
||||
button.append(buildArrowIcon());
|
||||
target.append(button);
|
||||
|
||||
let disposed = false;
|
||||
let boundContainer = null;
|
||||
let resizeObserver = null;
|
||||
let mutationObserver = null;
|
||||
let refreshFrame = 0;
|
||||
|
||||
const hide = () => {
|
||||
button.classList.remove('is-visible');
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.tabIndex = -1;
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (disposed || refreshFrame) return;
|
||||
refreshFrame = window.requestAnimationFrame(() => {
|
||||
refreshFrame = 0;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const unbindContainer = () => {
|
||||
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
boundContainer = null;
|
||||
};
|
||||
|
||||
const bindContainer = () => {
|
||||
const nextContainer = resolveElement(scrollContainer);
|
||||
if (!(nextContainer instanceof Element)) {
|
||||
if (boundContainer) unbindContainer();
|
||||
return null;
|
||||
}
|
||||
if (nextContainer === boundContainer) return boundContainer;
|
||||
|
||||
unbindContainer();
|
||||
boundContainer = nextContainer;
|
||||
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(scheduleRefresh);
|
||||
resizeObserver.observe(boundContainer);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
mutationObserver = new MutationObserver(scheduleRefresh);
|
||||
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return boundContainer;
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
if (disposed) return;
|
||||
const container = bindContainer();
|
||||
if (!container) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollHeight = Number(container.scrollHeight || 0);
|
||||
const clientHeight = Number(container.clientHeight || 0);
|
||||
const scrollTop = Number(container.scrollTop || 0);
|
||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
||||
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
||||
|
||||
button.classList.toggle('is-visible', shouldShow);
|
||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||
button.tabIndex = shouldShow ? 0 : -1;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = bindContainer();
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
scheduleRefresh();
|
||||
};
|
||||
|
||||
button.addEventListener('click', scrollToBottom);
|
||||
window.addEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
||||
window.requestAnimationFrame(refresh);
|
||||
|
||||
return {
|
||||
button,
|
||||
refresh: scheduleRefresh,
|
||||
cleanup() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (refreshFrame) {
|
||||
window.cancelAnimationFrame(refreshFrame);
|
||||
refreshFrame = 0;
|
||||
}
|
||||
unbindContainer();
|
||||
window.removeEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
||||
button.removeEventListener('click', scrollToBottom);
|
||||
button.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js';
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
|
||||
@@ -33,7 +33,7 @@ function getTotalUnreadMessages() {
|
||||
|
||||
function navigateWithGuestRules(pageId, navigate) {
|
||||
if (state.session.isAuthorized) {
|
||||
navigate(getToolbarNavigationTarget(pageId));
|
||||
navigate(pageId);
|
||||
return;
|
||||
}
|
||||
if (pageId === 'messages-list') {
|
||||
@@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(getToolbarNavigationTarget(pageId));
|
||||
navigate(pageId);
|
||||
}
|
||||
|
||||
export function renderToolbar(currentPageId, navigate) {
|
||||
@@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
btn.append(badge);
|
||||
}
|
||||
if (item.pageId === 'channels-list') {
|
||||
btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list')));
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
openArweaveAttachmentManager,
|
||||
markArweaveAttachmentPlaced,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
const label = String(title || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
if (avatar?.ar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readArweaveAttachmentHistory,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
@@ -73,13 +74,14 @@ export function render({ navigate }) {
|
||||
<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>
|
||||
<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="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="text-btn" 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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.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';
|
||||
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [],
|
||||
rightActions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
const headerLeft = header.querySelector('.header-left');
|
||||
let threadHeaderButton = null;
|
||||
if (headerLeft) {
|
||||
const channelsListButton = document.createElement('button');
|
||||
channelsListButton.type = 'button';
|
||||
channelsListButton.className = 'icon-btn';
|
||||
channelsListButton.textContent = '↑';
|
||||
channelsListButton.title = 'К списку каналов';
|
||||
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
||||
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
||||
headerLeft.append(channelsListButton);
|
||||
|
||||
threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
headerLeft.append(threadHeaderButton);
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
|
||||
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 });
|
||||
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
@@ -234,6 +233,12 @@ function buildThreadRoute(messageRef, selector) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const name = String(channelName || '').trim();
|
||||
return `${ownerBch}/${name}`;
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
@@ -381,18 +386,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
if (txId) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: txId ? { ar: txId } : null,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -1249,6 +1253,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
async function loadFromApi(route, channelId) {
|
||||
const currentSessionLogin = String(state.session.login || '').trim();
|
||||
const isAuthorized = !!currentSessionLogin;
|
||||
let unreadCount = 0;
|
||||
let messagesCount = 0;
|
||||
let cachedFeed = null;
|
||||
const ensureFeed = async () => {
|
||||
if (cachedFeed) return cachedFeed;
|
||||
@@ -1309,6 +1315,9 @@ async function loadFromApi(route, channelId) {
|
||||
}
|
||||
|
||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||
let unreadCount = 0;
|
||||
let messagesCount = 0;
|
||||
|
||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||
@@ -1358,6 +1367,8 @@ async function loadFromApi(route, channelId) {
|
||||
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
||||
throw new Error('Канал не найден.');
|
||||
}
|
||||
unreadCount = Number(channel?.unreadCount || 0);
|
||||
messagesCount = Number(channel?.messagesCount || 0);
|
||||
selector = {
|
||||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||||
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
||||
@@ -1374,6 +1385,7 @@ async function loadFromApi(route, channelId) {
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
let reverseChannelMissingWarning = '';
|
||||
let mergedMessages = [...messages];
|
||||
if (!messagesCount) messagesCount = mergedMessages.length;
|
||||
|
||||
const currentLogin = currentSessionLogin;
|
||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||
@@ -1445,6 +1457,8 @@ async function loadFromApi(route, channelId) {
|
||||
posts,
|
||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||
reverseChannelMissingWarning,
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
selector,
|
||||
@@ -1905,6 +1919,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
screen.append(reverseWarning);
|
||||
}
|
||||
|
||||
if (Number(channelData.unreadCount || 0) > 0) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
|
||||
screen.append(unreadLine);
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = 'Подписаться на канал';
|
||||
@@ -1995,7 +2016,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
@@ -2033,19 +2054,20 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: channelHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
],
|
||||
});
|
||||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
@@ -2056,6 +2078,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
};
|
||||
let activeSelector = null;
|
||||
@@ -2294,6 +2317,19 @@ export function render({ navigate, route, chrome }) {
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
activeSelector = apiData?.selector || null;
|
||||
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
|
||||
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
|
||||
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
|
||||
void authService.upsertUserSetting({
|
||||
login: state.session.login,
|
||||
settingType: 1,
|
||||
settingKey,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: lastSeenCount,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
}).catch(() => {});
|
||||
}
|
||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
||||
const openEntrypointHistory = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
function createChannelAvatar(channel = {}) {
|
||||
return renderAvatar({
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'small',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
const row = document.createElement('article');
|
||||
row.className = 'channel-row';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||||
<div class="channel-row-main">
|
||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.prepend(createChannelAvatar(channel));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
@@ -928,6 +938,9 @@ function closeTopChannelsMenu(listState) {
|
||||
listState.topMenuCleanup();
|
||||
}
|
||||
listState.topMenuCleanup = null;
|
||||
listState.topMenuAnchor?.setAttribute('aria-expanded', 'false');
|
||||
listState.topMenuAnchor?.classList.remove('menu-open-pressed');
|
||||
listState.topMenuAnchor = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||
@@ -939,7 +952,6 @@ function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onSubscribeChannel,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
@@ -951,7 +963,7 @@ function openTopChannelsMenu({
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 320;
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
@@ -968,23 +980,18 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ divider: true },
|
||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ divider: true },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ label: 'Создать канал', action: () => onSubscribeChannel?.() },
|
||||
{ divider: true },
|
||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1001,6 +1008,9 @@ function openTopChannelsMenu({
|
||||
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
anchorEl.classList.add('menu-open-pressed');
|
||||
listState.topMenuAnchor = anchorEl;
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
@@ -1206,16 +1216,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
if (channel.avaAr) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||
avatar.append(img);
|
||||
} else {
|
||||
avatar.textContent = channel.avatar;
|
||||
}
|
||||
const avatar = createChannelAvatar(channel);
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
@@ -1237,7 +1238,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.type = 'button';
|
||||
menuButton.className = 'channel-menu-trigger';
|
||||
menuButton.textContent = '…';
|
||||
menuButton.append(createOverflowDots());
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(menuButton);
|
||||
@@ -1357,6 +1358,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
topMenuAnchor: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
@@ -1373,63 +1375,36 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.type = 'button';
|
||||
backBtn.className = 'icon-btn channels-top-back-btn';
|
||||
backBtn.textContent = '←';
|
||||
backBtn.setAttribute('aria-label', 'Назад');
|
||||
backBtn.addEventListener('click', () => navigateBack());
|
||||
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
|
||||
const findChannelBtn = document.createElement('button');
|
||||
findChannelBtn.type = 'button';
|
||||
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
||||
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
||||
findChannelBtn.title = 'Найти канал';
|
||||
const findChannelIcon = document.createElement('span');
|
||||
findChannelIcon.className = 'channels-search-icon';
|
||||
findChannelIcon.setAttribute('aria-hidden', 'true');
|
||||
findChannelBtn.append(findChannelIcon);
|
||||
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||
|
||||
const createInMyBtn = document.createElement('button');
|
||||
createInMyBtn.type = 'button';
|
||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||
createInMyBtn.textContent = '+';
|
||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
const topMenuBtn = document.createElement('button');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.setAttribute('aria-haspopup', 'menu');
|
||||
topMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.textContent = '⋮';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
if (listState.topMenuAnchor === topMenuBtn) {
|
||||
closeTopChannelsMenu(listState);
|
||||
return;
|
||||
}
|
||||
openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
onSubscribeChannel: () => openSimpleSubscribeModal({
|
||||
kind: 'channel',
|
||||
kindLabel: 'Добавить канал',
|
||||
submitLabel: 'Добавить',
|
||||
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
topBarLeft.append(backBtn, topTitle);
|
||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topBarRight);
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
@@ -1450,10 +1425,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
findChannelBtn.style.display = '';
|
||||
createInMyBtn.style.display = '';
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
+143
-61
@@ -1,4 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -27,10 +29,55 @@ import {
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function createChatHeaderParts(login) {
|
||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||
|
||||
const avatarSlot = document.createElement('span');
|
||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||
const initialAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.append(initialAvatar);
|
||||
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'chat-header-login';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
loginEl.textContent = cleanLogin;
|
||||
|
||||
void loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
if (!avatarSlot.isConnected) return;
|
||||
const upgradedAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName: String(snapshot?.firstName || '').trim(),
|
||||
lastName: String(snapshot?.lastName || '').trim(),
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.replaceChildren(upgradedAvatar);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { centerNode: loginEl, avatarSlot };
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
@@ -572,12 +619,27 @@ function scrollToUnreadSeparator(list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
|
||||
function renderLog(
|
||||
list,
|
||||
chatId,
|
||||
{
|
||||
onOpenActions,
|
||||
markAsRead = true,
|
||||
scrollMode = 'latest',
|
||||
showUnreadSeparator = true,
|
||||
unreadSeparatorMessageKey = '',
|
||||
} = {},
|
||||
) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||
messages.forEach((msg) => {
|
||||
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
|
||||
const isUnreadBoundary = showUnreadSeparator
|
||||
&& !unreadSeparatorInserted
|
||||
&& separatorMessageKey
|
||||
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
|
||||
if (isUnreadBoundary) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'chat-unread-separator';
|
||||
const label = document.createElement('span');
|
||||
@@ -781,12 +843,8 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
}
|
||||
}
|
||||
|
||||
function setChatKeyboardOpen(isOpen) {
|
||||
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
|
||||
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
|
||||
}
|
||||
|
||||
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) || {
|
||||
@@ -800,12 +858,53 @@ export function render({ navigate, route, chrome }) {
|
||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
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;
|
||||
let historyNextBeforeMessageKey = '';
|
||||
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,
|
||||
markAsRead,
|
||||
scrollMode,
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
};
|
||||
|
||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorVisible = false;
|
||||
if (rerender) {
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
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)) {
|
||||
@@ -819,13 +918,13 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleStartCall = async (mode = 'audio') => {
|
||||
try {
|
||||
await startOutgoingCall(chatId, { mode });
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -848,17 +947,12 @@ export function render({ navigate, route, chrome }) {
|
||||
unread: false,
|
||||
rawBlobB64: String(result?.localBlobB64 || ''),
|
||||
});
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: 'latest',
|
||||
});
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
notifyUnreadStateUpdated();
|
||||
};
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
|
||||
const historyLoader = document.createElement('div');
|
||||
historyLoader.className = 'dm-history-loader';
|
||||
historyLoader.hidden = true;
|
||||
@@ -872,9 +966,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
{
|
||||
@@ -885,7 +979,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onClick: () => handleStartCall('audio'),
|
||||
},
|
||||
{
|
||||
label: '⋮',
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
@@ -942,8 +1036,9 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
@@ -1010,29 +1105,6 @@ export function render({ navigate, route, chrome }) {
|
||||
let inputFocused = false;
|
||||
let emojiPickerOpen = false;
|
||||
let emojiSelection = null;
|
||||
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
const appShell = document.querySelector('.app-shell');
|
||||
|
||||
const setKeyboardInset = (valuePx = 0) => {
|
||||
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
|
||||
};
|
||||
|
||||
const syncKeyboardUi = () => {
|
||||
const viewport = window.visualViewport || null;
|
||||
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
|
||||
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
|
||||
const keyboardInset = viewportShrunk
|
||||
? Math.max(0, baseViewportHeight - viewportHeight)
|
||||
: 0;
|
||||
setKeyboardInset(keyboardInset);
|
||||
setChatKeyboardOpen(inputFocused && viewportShrunk);
|
||||
if (viewportShrunk && window.scrollY !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
if (inputFocused) {
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
}
|
||||
};
|
||||
|
||||
const setHistoryLoadingState = (isLoading) => {
|
||||
historyLoader.hidden = !isLoading;
|
||||
@@ -1235,20 +1307,21 @@ export function render({ navigate, route, chrome }) {
|
||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
}
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
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}`;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
|
||||
try {
|
||||
@@ -1288,7 +1361,7 @@ export function render({ navigate, route, chrome }) {
|
||||
cancelReplyMode({ restoreDraft: false });
|
||||
}
|
||||
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
if (localRevisionApplied) {
|
||||
notifyUnreadStateUpdated();
|
||||
}
|
||||
@@ -1331,7 +1404,7 @@ export function render({ navigate, route, chrome }) {
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1393,7 +1466,7 @@ export function render({ navigate, route, chrome }) {
|
||||
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
|
||||
historyBootstrapped = true;
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
||||
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
||||
if (preserveScroll) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
|
||||
@@ -1445,12 +1518,12 @@ export function render({ navigate, route, chrome }) {
|
||||
input?.addEventListener('focus', () => {
|
||||
rememberEmojiSelection();
|
||||
inputFocused = true;
|
||||
syncKeyboardUi();
|
||||
scrollToLatestMessage(log);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (inputFocused) scrollToLatestMessage(log);
|
||||
});
|
||||
});
|
||||
input?.addEventListener('blur', () => {
|
||||
inputFocused = false;
|
||||
setChatKeyboardOpen(false);
|
||||
});
|
||||
emojiToggle?.setAttribute('aria-expanded', 'false');
|
||||
emojiToggle?.addEventListener('pointerdown', (event) => {
|
||||
@@ -1493,25 +1566,34 @@ 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, () => {
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
|
||||
renderChatLog({ scrollMode: 'latest' });
|
||||
});
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
};
|
||||
|
||||
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
||||
window.addEventListener('resize', syncKeyboardUi);
|
||||
|
||||
chrome?.setComposer(form);
|
||||
wrap.append(historyLoader, log);
|
||||
screen.append(wrap);
|
||||
chrome?.setComposer(form);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (markChatRead(chatId) > 0) {
|
||||
notifyUnreadStateUpdated();
|
||||
@@ -1519,18 +1601,18 @@ export function render({ navigate, route, chrome }) {
|
||||
}, 220);
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
window.requestAnimationFrame(() => {
|
||||
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
|
||||
boundScrollContainer = wrap;
|
||||
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
|
||||
void loadHistoryPage({ preserveScroll: true });
|
||||
});
|
||||
screen.cleanup = () => {
|
||||
setChatKeyboardOpen(false);
|
||||
setKeyboardInset(0);
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
stopAllTwemojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
||||
window.removeEventListener('resize', syncKeyboardUi);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
chrome?.setComposer(null);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
getChatMessages,
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
const SVG_CHEVRON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
const dmAvatarPendingByLogin = new Map();
|
||||
|
||||
const RELATION_ORDER = new Map([
|
||||
['close_friend', 0],
|
||||
['contact', 1],
|
||||
['none', 2],
|
||||
]);
|
||||
|
||||
async function loadDmAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
@@ -37,13 +50,14 @@ async function loadDmAvatarSnapshot(login) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createDmAvatar(login) {
|
||||
function createDmAvatar(login, { className = '' } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||
@@ -58,6 +72,7 @@ function createDmAvatar(login) {
|
||||
: null,
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
upgraded.classList.add('avatar');
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -65,10 +80,72 @@ function createDmAvatar(login) {
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function resolveLastMessagePreview(text = '') {
|
||||
const parsed = parseDmTechBlocks(String(text || ''));
|
||||
const display = String(parsed.displayText || '').trim();
|
||||
return display || '';
|
||||
function normalizeRelationFlag(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (clean === 'close_friend' || clean === 'contact') return clean;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function relationOrder(flag) {
|
||||
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
|
||||
}
|
||||
|
||||
function relationLabel(flag) {
|
||||
switch (normalizeRelationFlag(flag)) {
|
||||
case 'close_friend':
|
||||
return 'близкий друг';
|
||||
case 'contact':
|
||||
return 'контакт';
|
||||
default:
|
||||
return 'не в контактах';
|
||||
}
|
||||
}
|
||||
|
||||
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
return `${normalized.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
async function resolveDialogPreview(dialog) {
|
||||
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
||||
if (!blobB64) return 'Диалог пока пуст.';
|
||||
|
||||
const cacheKey = [
|
||||
blobB64,
|
||||
String(state.session.login || '').trim().toLowerCase(),
|
||||
String(state.session.storagePwdInMemory || '').trim(),
|
||||
].join('|');
|
||||
|
||||
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
|
||||
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
|
||||
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const parsed = authService.parseSignedMessageBlob(blobB64);
|
||||
const decrypted = await authService.decryptSignedMessageContent({
|
||||
parsed,
|
||||
blobB64,
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
|
||||
const display = clipPreviewText(String(parsedText.displayText || '').trim());
|
||||
const result = display || 'Сообщение';
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = 'Сообщение недоступно';
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
@@ -82,39 +159,141 @@ function formatChatRowTime(ts) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||
function compareChatRows(a, b) {
|
||||
const timeA = Number(a?.lastMessageTimeMs || 0);
|
||||
const timeB = Number(b?.lastMessageTimeMs || 0);
|
||||
if (timeA !== timeB) return timeB - timeA;
|
||||
const nameA = String(a?.peerLogin || '').toLowerCase();
|
||||
const nameB = String(b?.peerLogin || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB, 'ru');
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const login = String(state.session.login || '').trim();
|
||||
|
||||
const head = document.createElement('header');
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand">
|
||||
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||
<div class="dm-head-id">
|
||||
<span class="dm-head-name"></span>
|
||||
<div class="dm-head-brand" aria-hidden="true"></div>
|
||||
<h1 class="dm-head-title">Чаты</h1>
|
||||
<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>
|
||||
<h1 class="dm-head-title">Контакты</h1>
|
||||
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
||||
`;
|
||||
const headName = head.querySelector('.dm-head-name');
|
||||
if (headName) headName.textContent = login;
|
||||
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
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;
|
||||
let menuBackdrop = null;
|
||||
|
||||
const closeHeadMenu = () => {
|
||||
menuPortal?.remove();
|
||||
menuBackdrop?.remove();
|
||||
menuPortal = null;
|
||||
menuBackdrop = null;
|
||||
menuButton?.setAttribute('aria-expanded', 'false');
|
||||
menuButton?.classList.remove('menu-open-pressed');
|
||||
menuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionHeadMenu = () => {
|
||||
if (!menuPortal || !menuButton) return;
|
||||
const rect = menuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuRect = menuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || menuPortal.offsetWidth || 190;
|
||||
const menuHeight = menuRect.height || menuPortal.offsetHeight || 56;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
menuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
if (!menuButton || menuPortal) return;
|
||||
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">
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeHeadMenu();
|
||||
navigate('contact-search-view');
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeHeadMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
menuBackdrop = backdrop;
|
||||
menuPortal = portal;
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
menuButton.classList.add('menu-open-pressed');
|
||||
menuWrap?.classList.add('is-open');
|
||||
positionHeadMenu();
|
||||
requestAnimationFrame(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 onMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !menuPortal) return;
|
||||
closeHeadMenu();
|
||||
menuButton?.focus();
|
||||
};
|
||||
const onMenuViewportChange = () => positionHeadMenu();
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onMenuKeydown);
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack dm-list';
|
||||
|
||||
function renderRow(item) {
|
||||
function renderRow(item) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const avatarEl = createDmAvatar(item.id);
|
||||
const relationFlag = normalizeRelationFlag(item.relationFlag);
|
||||
const relationBadge = relationFlag === 'none'
|
||||
? 'не в контактах'
|
||||
: relationLabel(relationFlag);
|
||||
const avatarEl = createDmAvatar(item.peerLogin);
|
||||
avatarEl.classList.add('avatar');
|
||||
const avatarWrap = document.createElement('div');
|
||||
avatarWrap.className = 'dm-av dm-av--default';
|
||||
@@ -123,14 +302,14 @@ export function render({ navigate, chrome }) {
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-titleline dm-row-titlewrap">
|
||||
<strong class="dm-row-title"></strong>
|
||||
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
||||
<span class="dm-contact-note">${relationBadge}</span>
|
||||
</div>
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<div class="dm-row-meta-col">
|
||||
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||
${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.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,11 +317,15 @@ export function render({ navigate, chrome }) {
|
||||
const titleEl = row.querySelector('.dm-row-title');
|
||||
const previewEl = row.querySelector('.dm-row-last-message');
|
||||
const timeEl = row.querySelector('.dm-row-time');
|
||||
if (titleEl) titleEl.textContent = String(item.name || '');
|
||||
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
|
||||
if (timeEl) timeEl.textContent = String(item.time || '');
|
||||
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
|
||||
if (previewEl) previewEl.textContent = 'Загрузка…';
|
||||
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
||||
row.prepend(avatarWrap);
|
||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
|
||||
void resolveDialogPreview(item).then((text) => {
|
||||
if (!previewEl?.isConnected) return;
|
||||
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
|
||||
});
|
||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -156,60 +339,64 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const relations = await loadCurrentRelations();
|
||||
const contacts = relations.outContacts || [];
|
||||
const payload = await authService.listContacts();
|
||||
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
||||
setContacts(contacts);
|
||||
list.innerHTML = '';
|
||||
|
||||
const contactRows = contacts.map((login) => {
|
||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
||||
const canonicalLogin = normalizeDmChatId(login);
|
||||
const chat = getChatMessages(canonicalLogin);
|
||||
const lastChat = chat[chat.length - 1];
|
||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||
return {
|
||||
id: canonicalLogin,
|
||||
name: preview?.name || login,
|
||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
unread,
|
||||
notInContacts: false,
|
||||
const byPeer = new Map();
|
||||
dialogs.forEach((dialog) => {
|
||||
const peerLogin = String(dialog?.peerLogin || '').trim();
|
||||
if (!peerLogin) return;
|
||||
const key = peerLogin.toLowerCase();
|
||||
const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
|
||||
const next = {
|
||||
id: peerLogin,
|
||||
peerLogin,
|
||||
relationFlag,
|
||||
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
||||
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
||||
unreadCount: Number(dialog?.unreadCount || 0),
|
||||
hasDialog: Boolean(dialog?.hasDialog),
|
||||
};
|
||||
const current = byPeer.get(key);
|
||||
if (!current) {
|
||||
byPeer.set(key, next);
|
||||
return;
|
||||
}
|
||||
const currentRank = relationOrder(current.relationFlag);
|
||||
const nextRank = relationOrder(relationFlag);
|
||||
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
|
||||
byPeer.set(key, next);
|
||||
}
|
||||
});
|
||||
|
||||
const allChatIds = Object.keys(state.chats || {})
|
||||
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
||||
.filter((id) => (getChatMessages(id) || []).length > 0);
|
||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
|
||||
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
|
||||
const extraRows = allChatIds
|
||||
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
|
||||
.map((login) => {
|
||||
const chat = getChatMessages(login);
|
||||
const lastChat = chat[chat.length - 1];
|
||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||
return {
|
||||
id: login,
|
||||
name: login,
|
||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
unread,
|
||||
notInContacts: true,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = [...contactRows, ...extraRows];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
||||
empty.textContent = 'Пока нет диалогов';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((item) => list.append(renderRow(item)));
|
||||
let dividerInserted = false;
|
||||
rows.forEach((item) => {
|
||||
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
list.append(divider);
|
||||
dividerInserted = true;
|
||||
}
|
||||
list.append(renderRow(item));
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
list.innerHTML = '';
|
||||
@@ -249,7 +436,16 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(divider, list);
|
||||
screen.append(list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
@@ -215,7 +216,7 @@ function buildGraphModel(graph, centerLogin) {
|
||||
let persistedCenterLogin = '';
|
||||
let persistedCenterHistory = [];
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
@@ -282,10 +283,7 @@ export function render({ navigate, route }) {
|
||||
else window.history.replaceState({}, '', nextPath);
|
||||
}
|
||||
|
||||
function setBackButtonState(backBtn) {
|
||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||
backBtn.disabled = centerHistory.length === 0;
|
||||
}
|
||||
|
||||
|
||||
function openSearchModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
@@ -490,36 +488,119 @@ export function render({ navigate, route }) {
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
if (requestId !== loadSeq) return;
|
||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
let topMenuPortal = null;
|
||||
let topMenuBackdrop = null;
|
||||
let topMenuButton = null;
|
||||
|
||||
const closeTopMenu = () => {
|
||||
topMenuPortal?.remove();
|
||||
topMenuBackdrop?.remove();
|
||||
topMenuPortal = null;
|
||||
topMenuBackdrop = null;
|
||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
topMenuButton?.classList.remove('menu-open-pressed');
|
||||
};
|
||||
|
||||
const positionTopMenu = () => {
|
||||
if (!topMenuPortal || !topMenuButton) return;
|
||||
const rect = topMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuRect = topMenuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || topMenuPortal.offsetWidth || 190;
|
||||
const menuHeight = menuRect.height || topMenuPortal.offsetHeight || 56;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
topMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
topMenuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openTopMenu = () => {
|
||||
if (!topMenuButton || topMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal network-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item network-head-menu-item" role="menuitem" data-action="find-person">
|
||||
<span>Найти человека</span>
|
||||
</button>
|
||||
`;
|
||||
portal.querySelector('[data-action="find-person"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeTopMenu();
|
||||
openSearchModal();
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeTopMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
topMenuBackdrop = backdrop;
|
||||
topMenuPortal = portal;
|
||||
topMenuButton.setAttribute('aria-expanded', 'true');
|
||||
topMenuButton.classList.add('menu-open-pressed');
|
||||
positionTopMenu();
|
||||
requestAnimationFrame(positionTopMenu);
|
||||
};
|
||||
|
||||
const toggleTopMenu = (event) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (topMenuPortal) closeTopMenu();
|
||||
else openTopMenu();
|
||||
};
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (!centerHistory.length) return;
|
||||
const prev = centerHistory.pop();
|
||||
if (!prev) {
|
||||
setBackButtonState(backBtnEl);
|
||||
return;
|
||||
}
|
||||
void load(prev, { pushHistory: false });
|
||||
},
|
||||
},
|
||||
rightActions: [
|
||||
{ label: 'Найти', onClick: openSearchModal },
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
className: 'network-top-more-btn',
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Меню связей',
|
||||
onClick: toggleTopMenu,
|
||||
},
|
||||
],
|
||||
});
|
||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||
setBackButtonState(backBtnEl);
|
||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||
header.classList.add('network-topbar');
|
||||
topMenuButton = header.querySelector('.network-top-more-btn');
|
||||
topMenuButton?.setAttribute('aria-haspopup', 'menu');
|
||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const onTopMenuOutsideClick = (event) => {
|
||||
if (!topMenuPortal) return;
|
||||
if (topMenuPortal.contains(event.target) || topMenuButton?.contains(event.target)) return;
|
||||
closeTopMenu();
|
||||
};
|
||||
const onTopMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !topMenuPortal) return;
|
||||
closeTopMenu();
|
||||
topMenuButton?.focus();
|
||||
};
|
||||
const onTopMenuViewportChange = () => positionTopMenu();
|
||||
document.addEventListener('click', onTopMenuOutsideClick);
|
||||
document.addEventListener('keydown', onTopMenuKeydown);
|
||||
window.addEventListener('resize', onTopMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onTopMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
closeTopMenu();
|
||||
document.removeEventListener('click', onTopMenuOutsideClick);
|
||||
document.removeEventListener('keydown', onTopMenuKeydown);
|
||||
window.removeEventListener('resize', onTopMenuViewportChange);
|
||||
window.removeEventListener('scroll', onTopMenuViewportChange, true);
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
@@ -542,11 +623,10 @@ export function render({ navigate, route }) {
|
||||
window.setTimeout(() => openSearchModal(), 0);
|
||||
}
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
@@ -560,8 +640,8 @@ export function render({ navigate, route }) {
|
||||
filterBar.append(chip);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header, filterBar);
|
||||
chrome?.setTopbar(header);
|
||||
stage.append(board, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,394 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
const CONNECTION_CLOSE_FRIEND = 10;
|
||||
const profileSnapshotCache = new Map();
|
||||
const profileSnapshotPending = new Map();
|
||||
|
||||
function connectionTypeLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND:
|
||||
return 'близкие друзья';
|
||||
default:
|
||||
return 'новую связь';
|
||||
}
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||
|
||||
function renderList(container) {
|
||||
const active = state.notificationsTab;
|
||||
container.innerHTML = '';
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'meta-muted';
|
||||
description.textContent = active === 'events'
|
||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
||||
|
||||
card.append(title, description, note);
|
||||
container.append(card);
|
||||
function normalizeItem(item) {
|
||||
return {
|
||||
kind: String(item?.kind || ''),
|
||||
createdAtMs: Number(item?.createdAtMs || 0),
|
||||
sourceLogin: String(item?.sourceLogin || ''),
|
||||
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
||||
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
||||
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
||||
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
||||
sourceText: String(item?.sourceText || ''),
|
||||
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
||||
targetLogin: String(item?.targetLogin || ''),
|
||||
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
||||
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
||||
targetBlockHash: String(item?.targetBlockHash || ''),
|
||||
profile: null,
|
||||
engagement: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ chrome } = {}) {
|
||||
function formatRelativeTime(value) {
|
||||
const ts = Number(value || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - ts);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
|
||||
if (diffMs < minute) return 'сейчас';
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
||||
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
||||
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
||||
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
||||
}
|
||||
|
||||
function profileField(snapshot, key) {
|
||||
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
||||
.find((field) => String(field?.key || '') === key);
|
||||
return String(row?.value || '').trim();
|
||||
}
|
||||
|
||||
async function loadCachedProfileSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
|
||||
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
|
||||
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
profileSnapshotCache.set(key, snapshot || null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
profileSnapshotCache.set(key, null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return null;
|
||||
});
|
||||
|
||||
profileSnapshotPending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function normalizeEngagement(source) {
|
||||
if (!source || typeof source !== 'object') return null;
|
||||
const likesCount = Math.max(0, Number(source.likesCount || 0));
|
||||
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
|
||||
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
|
||||
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
|
||||
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
|
||||
|
||||
const result = {
|
||||
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
|
||||
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
|
||||
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
|
||||
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
|
||||
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
|
||||
};
|
||||
|
||||
return Object.values(result).some((count) => count > 0) ? result : null;
|
||||
}
|
||||
|
||||
async function loadSourceEngagement(item) {
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
const blockHash = String(item?.sourceBlockHash || '').trim();
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
|
||||
try {
|
||||
const payload = await authService.getMessageThread(
|
||||
{ blockchainName, blockNumber, blockHash },
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
String(state.session.login || '').trim(),
|
||||
);
|
||||
return normalizeEngagement(payload?.focus);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichItem(item, activeTab) {
|
||||
const [profile, engagement] = await Promise.all([
|
||||
loadCachedProfileSnapshot(item.sourceLogin),
|
||||
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
|
||||
]);
|
||||
return { ...item, profile, engagement };
|
||||
}
|
||||
|
||||
function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events'
|
||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderIdentity(item) {
|
||||
const profile = item.profile;
|
||||
const firstName = profileField(profile, 'first_name');
|
||||
const lastName = profileField(profile, 'last_name');
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
||||
const avatar = profile?.avatar?.txId
|
||||
? {
|
||||
ar: String(profile.avatar.txId || '').trim(),
|
||||
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notification-identity';
|
||||
header.append(renderUserAvatar({
|
||||
login: item.sourceLogin || 'unknown',
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'notification-identity-text';
|
||||
|
||||
const primary = document.createElement('div');
|
||||
primary.className = 'notification-identity-primary';
|
||||
const name = document.createElement('strong');
|
||||
name.className = 'notification-person-name';
|
||||
name.textContent = fullName;
|
||||
primary.append(name);
|
||||
|
||||
const login = String(item.sourceLogin || '').trim();
|
||||
if (login) {
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'notification-login';
|
||||
loginEl.textContent = `@${login}`;
|
||||
primary.append(loginEl);
|
||||
}
|
||||
|
||||
const relative = formatRelativeTime(item.createdAtMs);
|
||||
if (relative) {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'notification-time-separator';
|
||||
separator.textContent = '·';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-time';
|
||||
time.textContent = relative;
|
||||
primary.append(separator, time);
|
||||
}
|
||||
|
||||
text.append(primary);
|
||||
header.append(text);
|
||||
return header;
|
||||
}
|
||||
|
||||
function renderEngagement(engagement) {
|
||||
if (!engagement) return null;
|
||||
|
||||
const stats = [
|
||||
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||
|
||||
if (!stats.length) return null;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'notification-engagement';
|
||||
stats.forEach(({ key, icon, label }) => {
|
||||
const stat = document.createElement('span');
|
||||
stat.className = 'notification-engagement-item';
|
||||
stat.title = label;
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'notification-engagement-icon';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconEl.textContent = icon;
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'notification-engagement-count';
|
||||
countEl.textContent = String(engagement[key]);
|
||||
stat.append(iconEl, countEl);
|
||||
row.append(stat);
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function notificationRoute(item, activeTab) {
|
||||
if (activeTab === 'events') {
|
||||
const login = String(item?.sourceLogin || '').trim();
|
||||
return login ? makeProfileRoute(login) : '';
|
||||
}
|
||||
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
|
||||
|
||||
return makeShineMessageRoute({
|
||||
messageBlockchainName: blockchainName,
|
||||
messageBlockNumber: blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function bindNotificationNavigation(row, routePath, navigate) {
|
||||
if (!routePath || typeof navigate !== 'function') return;
|
||||
|
||||
row.classList.add('notification-card--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'link');
|
||||
|
||||
const open = () => navigate(routePath);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
open();
|
||||
});
|
||||
}
|
||||
|
||||
function renderItem(item, activeTab, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'card stack notification-card';
|
||||
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||
row.append(renderIdentity(item));
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'events') {
|
||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
row.append(action);
|
||||
|
||||
if (activeTab === 'replies') {
|
||||
const body = document.createElement('p');
|
||||
body.className = 'notification-content';
|
||||
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||
row.append(body);
|
||||
|
||||
const engagement = renderEngagement(item.engagement);
|
||||
if (engagement) row.append(engagement);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.innerHTML = `
|
||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
||||
data-tab="replies"
|
||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
>Ответы</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
||||
data-tab="events"
|
||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
>События</button>
|
||||
`;
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
renderList(list);
|
||||
|
||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
let requestSeq = 0;
|
||||
|
||||
async function load() {
|
||||
const seq = ++requestSeq;
|
||||
const activeTab = state.notificationsTab;
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
|
||||
try {
|
||||
const payload = await authService.getNotifications(50);
|
||||
if (seq !== requestSeq) return;
|
||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
||||
.map(normalizeItem);
|
||||
if (!baseItems.length) {
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
||||
if (seq !== requestSeq) return;
|
||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось загрузить уведомления';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
||||
card.append(title, text);
|
||||
list.replaceChildren(card);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveNotificationTab(nextTab) {
|
||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||||
state.notificationsTab = normalizedTab;
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||||
const selected = node.dataset.tab === normalizedTab;
|
||||
node.classList.toggle('is-active', selected);
|
||||
node.dataset.selected = selected ? 'true' : 'false';
|
||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||||
setActiveNotificationTab(state.notificationsTab);
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.notificationsTab = btn.dataset.tab;
|
||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderList(list);
|
||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||
if (state.notificationsTab === nextTab) {
|
||||
setActiveNotificationTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNotificationTab(nextTab);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -101,22 +102,105 @@ export function render({ navigate, chrome }) {
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-actions profile-top-actions">
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<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 topActions = topbar.querySelector('.profile-top-actions');
|
||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
let profileMenuBackdrop = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuBackdrop?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuBackdrop = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuButton?.classList.remove('menu-open-pressed');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuRect = profileMenuPortal.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || profileMenuPortal.offsetWidth || 210;
|
||||
const menuHeight = menuRect.height || profileMenuPortal.offsetHeight || 144;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
const below = rect.bottom + 7;
|
||||
const top = below + menuHeight <= window.innerHeight - margin
|
||||
? below
|
||||
: Math.max(margin, rect.top - menuHeight - 7);
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
if (!profileMenuButton || profileMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const goTo = (route) => {
|
||||
closeProfileMenu();
|
||||
navigate(route);
|
||||
};
|
||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'ui-menu-dim-layer';
|
||||
backdrop.addEventListener('click', closeProfileMenu);
|
||||
document.body.append(backdrop, portal);
|
||||
profileMenuBackdrop = backdrop;
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuButton.classList.add('menu-open-pressed');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
requestAnimationFrame(positionProfileMenu);
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (profileMenuPortal) closeProfileMenu();
|
||||
else openProfileMenu();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenuPortal) return;
|
||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||
closeProfileMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||
closeProfileMenu();
|
||||
profileMenuButton?.focus();
|
||||
});
|
||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '../services/arweave-wallet-service.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
|
||||
import {
|
||||
calcLimitTopupPriceLamports,
|
||||
getLimitStepBytes,
|
||||
@@ -24,6 +26,12 @@ import {
|
||||
getShineUsersEconomyConfig,
|
||||
updateShineUserPdaOnSolana,
|
||||
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
|
||||
import {
|
||||
buildBlockchainSnapshotFile,
|
||||
clearStoredBlockchainSnapshot,
|
||||
readStoredBlockchainSnapshot,
|
||||
saveStoredBlockchainSnapshot,
|
||||
} from '../services/shine-blockchain-snapshot-service.js';
|
||||
|
||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||
|
||||
@@ -40,6 +48,17 @@ function formatKbFromBytes(rawBytes) {
|
||||
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
|
||||
}
|
||||
|
||||
function formatBytesRu(rawBytes) {
|
||||
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
return bytes.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function normalizeHex64(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
|
||||
}
|
||||
|
||||
function lamportsToSolText(lamportsBigInt) {
|
||||
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
|
||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
|
||||
@@ -461,6 +480,55 @@ export function render({ navigate }) {
|
||||
arweaveWalletCtx = null;
|
||||
}
|
||||
|
||||
async function fetchServerBlockchainState() {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const user = await authService.getUser(login);
|
||||
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||||
return {
|
||||
login,
|
||||
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
|
||||
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||||
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||||
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
|
||||
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWalletSigningMaterial() {
|
||||
const { login, storagePwd } = sessionArgsOrThrow();
|
||||
let saved;
|
||||
try {
|
||||
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
let rootKey = String(saved?.rootKey || '').trim();
|
||||
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||||
const clientKey = String(saved?.clientKey || '').trim();
|
||||
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
|
||||
if (rootKey && blockchainKey) {
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||
}
|
||||
|
||||
const password = window.prompt(
|
||||
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена пользователем');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||||
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||||
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||||
|
||||
const shouldSave = window.confirm(
|
||||
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||||
);
|
||||
if (shouldSave) {
|
||||
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||||
}
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||
}
|
||||
|
||||
function styleSupportInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
@@ -1245,6 +1313,14 @@ export function render({ navigate }) {
|
||||
void renderShineBlockchainWallet();
|
||||
});
|
||||
|
||||
const solanaPublishBtn = document.createElement('button');
|
||||
solanaPublishBtn.className = 'primary-btn';
|
||||
solanaPublishBtn.style.width = '100%';
|
||||
solanaPublishBtn.textContent = 'Закрепление в Solana';
|
||||
solanaPublishBtn.addEventListener('click', () => {
|
||||
void renderSolanaPublishWallet();
|
||||
});
|
||||
|
||||
const supportBtn = document.createElement('button');
|
||||
supportBtn.className = 'primary-btn';
|
||||
supportBtn.style.width = '100%';
|
||||
@@ -1253,7 +1329,7 @@ export function render({ navigate }) {
|
||||
void renderSupportHub();
|
||||
});
|
||||
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
|
||||
content.append(card);
|
||||
setStatus('Выберите тип кошелька.');
|
||||
}
|
||||
@@ -1339,63 +1415,13 @@ export function render({ navigate }) {
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
||||
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
|
||||
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
||||
`;
|
||||
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
||||
const syncBtn = actions.querySelector('#sync-shine-solana');
|
||||
const topupBtn = actions.querySelector('#topup-shine-limit');
|
||||
|
||||
const fetchServerState = async () => {
|
||||
const user = await authService.getUser(String(state.session.login || '').trim());
|
||||
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||||
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
|
||||
return {
|
||||
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||||
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||||
lastNumber,
|
||||
lastHash: String(user.serverLastGlobalHash || ''),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveWalletSigningMaterial = async () => {
|
||||
const { login, storagePwd } = sessionArgsOrThrow();
|
||||
let saved;
|
||||
try {
|
||||
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
let rootKey = String(saved?.rootKey || '').trim();
|
||||
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||||
const clientKey = String(saved?.clientKey || '').trim();
|
||||
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
|
||||
if (rootKey && blockchainKey) {
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||
}
|
||||
|
||||
const password = window.prompt(
|
||||
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена пользователем');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||||
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||||
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||||
|
||||
const shouldSave = window.confirm(
|
||||
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||||
);
|
||||
if (shouldSave) {
|
||||
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||||
}
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||||
};
|
||||
|
||||
const setButtonsDisabled = (disabled) => {
|
||||
refreshBtn.disabled = disabled;
|
||||
syncBtn.disabled = disabled;
|
||||
topupBtn.disabled = disabled;
|
||||
};
|
||||
|
||||
@@ -1407,7 +1433,7 @@ export function render({ navigate }) {
|
||||
login: String(state.session.login || '').trim(),
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
}),
|
||||
fetchServerState(),
|
||||
fetchServerBlockchainState(),
|
||||
]);
|
||||
if (modeToken !== activeModeToken) return;
|
||||
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
||||
@@ -1423,9 +1449,6 @@ export function render({ navigate }) {
|
||||
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||
|
||||
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||||
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||||
|
||||
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
@@ -1435,35 +1458,6 @@ export function render({ navigate }) {
|
||||
}
|
||||
};
|
||||
|
||||
syncBtn.addEventListener('click', async () => {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const [serverState, signing] = await Promise.all([
|
||||
fetchServerState(),
|
||||
resolveWalletSigningMaterial(),
|
||||
]);
|
||||
const result = await updateShineUserPdaOnSolana({
|
||||
login: String(state.session.login || '').trim(),
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
||||
additionalLimitBytes: 0n,
|
||||
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||||
nextLastBlockNumber: serverState.lastNumber,
|
||||
nextLastBlockHashHex: serverState.lastHash,
|
||||
});
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
|
||||
await refreshUsage();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
topupBtn.addEventListener('click', async () => {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
@@ -1517,6 +1511,357 @@ export function render({ navigate }) {
|
||||
await refreshUsage();
|
||||
}
|
||||
|
||||
async function renderSolanaPublishWallet() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const backBtn = createModeBackButton(renderWalletChoice);
|
||||
|
||||
const summaryCard = document.createElement('div');
|
||||
summaryCard.className = 'card stack';
|
||||
summaryCard.innerHTML = `
|
||||
<h2 style="margin:0;">Закрепление в Solana</h2>
|
||||
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
|
||||
`;
|
||||
|
||||
const publicationStatus = document.createElement('div');
|
||||
publicationStatus.className = 'card stack';
|
||||
publicationStatus.innerHTML = `
|
||||
<h3 style="margin:0;">Статус публикации</h3>
|
||||
<p class="meta-muted" id="publish-status-main">—</p>
|
||||
<p class="meta-muted" id="publish-status-local">—</p>
|
||||
`;
|
||||
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
|
||||
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
|
||||
|
||||
const pdaCard = document.createElement('div');
|
||||
pdaCard.className = 'card stack';
|
||||
pdaCard.innerHTML = `
|
||||
<h3 style="margin:0;">Что закреплено в PDA</h3>
|
||||
<p class="meta-muted" id="pda-name">Блокчейн: —</p>
|
||||
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: —</p>
|
||||
<p class="meta-muted" id="pda-last">Крайний блок: —</p>
|
||||
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||
<p class="meta-muted" id="pda-used">Размер: —</p>
|
||||
<p class="meta-muted" id="pda-limit">Лимит: —</p>
|
||||
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: —</p>
|
||||
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
|
||||
`;
|
||||
|
||||
const serverCard = document.createElement('div');
|
||||
serverCard.className = 'card stack';
|
||||
serverCard.innerHTML = `
|
||||
<h3 style="margin:0;">Что реально на сервере</h3>
|
||||
<p class="meta-muted" id="server-name">Блокчейн: —</p>
|
||||
<p class="meta-muted" id="server-last">Крайний блок: —</p>
|
||||
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||
<p class="meta-muted" id="server-size">Размер: —</p>
|
||||
<p class="meta-muted" id="server-updated">Обновлено: —</p>
|
||||
`;
|
||||
|
||||
const localCard = document.createElement('div');
|
||||
localCard.className = 'card stack';
|
||||
localCard.innerHTML = `
|
||||
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
|
||||
<p class="meta-muted" id="local-state">Слепок: —</p>
|
||||
<p class="meta-muted" id="local-transport">Способ: —</p>
|
||||
<p class="meta-muted" id="local-last">Крайний блок: —</p>
|
||||
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
|
||||
<p class="meta-muted" id="local-size">Размер: —</p>
|
||||
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: —</p>
|
||||
<p class="meta-muted" id="local-created">Создан: —</p>
|
||||
<p class="meta-muted" id="local-solana">Solana: —</p>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
|
||||
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
|
||||
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
|
||||
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
|
||||
`;
|
||||
const refreshBtn = actions.querySelector('#refresh-solana-publish');
|
||||
const createBtn = actions.querySelector('#create-solana-snapshot');
|
||||
const publishBtn = actions.querySelector('#publish-solana-snapshot');
|
||||
const clearBtn = actions.querySelector('#clear-solana-snapshot');
|
||||
|
||||
const pdaNameEl = pdaCard.querySelector('#pda-name');
|
||||
const pdaAddressEl = pdaCard.querySelector('#pda-address');
|
||||
const pdaLastEl = pdaCard.querySelector('#pda-last');
|
||||
const pdaHashEl = pdaCard.querySelector('#pda-hash');
|
||||
const pdaUsedEl = pdaCard.querySelector('#pda-used');
|
||||
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
|
||||
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
|
||||
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
|
||||
|
||||
const serverNameEl = serverCard.querySelector('#server-name');
|
||||
const serverLastEl = serverCard.querySelector('#server-last');
|
||||
const serverHashEl = serverCard.querySelector('#server-hash');
|
||||
const serverSizeEl = serverCard.querySelector('#server-size');
|
||||
const serverUpdatedEl = serverCard.querySelector('#server-updated');
|
||||
|
||||
const localStateEl = localCard.querySelector('#local-state');
|
||||
const localTransportEl = localCard.querySelector('#local-transport');
|
||||
const localLastEl = localCard.querySelector('#local-last');
|
||||
const localHashEl = localCard.querySelector('#local-hash');
|
||||
const localSizeEl = localCard.querySelector('#local-size');
|
||||
const localArweaveEl = localCard.querySelector('#local-arweave');
|
||||
const localCreatedEl = localCard.querySelector('#local-created');
|
||||
const localSolanaEl = localCard.querySelector('#local-solana');
|
||||
|
||||
function setButtonsDisabled(disabled) {
|
||||
refreshBtn.disabled = disabled;
|
||||
createBtn.disabled = disabled;
|
||||
publishBtn.disabled = disabled;
|
||||
clearBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
async function refreshState() {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const [usage, serverState] = await Promise.all([
|
||||
getShineBlockchainUsage({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
}),
|
||||
fetchServerBlockchainState(),
|
||||
]);
|
||||
const localSnapshot = readStoredBlockchainSnapshot(login);
|
||||
if (modeToken !== activeModeToken) return;
|
||||
|
||||
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
|
||||
if (!usage.arweaveTxId) {
|
||||
publicationMainEl.textContent = 'не опубликовано';
|
||||
} else if (pdaDelta > 0) {
|
||||
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
|
||||
} else {
|
||||
publicationMainEl.textContent = 'опубликовано и полностью актуально';
|
||||
}
|
||||
|
||||
let localStatusText = 'Локального слепка нет.';
|
||||
const localMatchesServer = localSnapshot
|
||||
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
|
||||
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
|
||||
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
|
||||
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
|
||||
if (localSnapshot?.txId && localMatchesServer) {
|
||||
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
|
||||
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
|
||||
} else {
|
||||
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
|
||||
}
|
||||
} else if (localSnapshot?.txId) {
|
||||
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
|
||||
}
|
||||
publicationLocalEl.textContent = localStatusText;
|
||||
|
||||
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
|
||||
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
|
||||
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||||
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||||
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
|
||||
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
|
||||
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
|
||||
if (usage.arweaveTxId) {
|
||||
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
|
||||
pdaArweaveLinkEl.style.pointerEvents = 'auto';
|
||||
pdaArweaveLinkEl.style.opacity = '1';
|
||||
} else {
|
||||
pdaArweaveLinkEl.href = '#';
|
||||
pdaArweaveLinkEl.style.pointerEvents = 'none';
|
||||
pdaArweaveLinkEl.style.opacity = '.55';
|
||||
}
|
||||
|
||||
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
|
||||
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
|
||||
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
|
||||
|
||||
if (localSnapshot?.txId) {
|
||||
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
|
||||
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
|
||||
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
|
||||
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
|
||||
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
|
||||
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
|
||||
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
|
||||
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
|
||||
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
|
||||
: 'Solana: слепок ещё не закреплён';
|
||||
} else {
|
||||
localStateEl.textContent = 'Слепок: отсутствует';
|
||||
localTransportEl.textContent = 'Способ: —';
|
||||
localLastEl.textContent = 'Крайний блок: —';
|
||||
localHashEl.textContent = 'Hash: —';
|
||||
localSizeEl.textContent = 'Размер: —';
|
||||
localArweaveEl.textContent = 'Arweave tx: —';
|
||||
localCreatedEl.textContent = 'Создан: —';
|
||||
localSolanaEl.textContent = 'Solana: —';
|
||||
}
|
||||
|
||||
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
|
||||
clearBtn.disabled = !localSnapshot;
|
||||
setStatus('Состояние публикации обновлено.');
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
publishBtn.disabled = true;
|
||||
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
refreshBtn.disabled = false;
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot() {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
setStatus('Собираю полный слепок блокчейна с сервера...');
|
||||
const [usage, serverState] = await Promise.all([
|
||||
getShineBlockchainUsage({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
}),
|
||||
fetchServerBlockchainState(),
|
||||
]);
|
||||
const snapshotFile = await buildBlockchainSnapshotFile({
|
||||
authService,
|
||||
login,
|
||||
blockchainName: usage.blockchainName || serverState.blockchainName,
|
||||
lastBlockNumber: serverState.lastNumber,
|
||||
});
|
||||
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
|
||||
if (modeToken !== activeModeToken) return;
|
||||
|
||||
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
|
||||
const uploaded = await openArweaveAttachmentManager({
|
||||
login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
historyOnly: false,
|
||||
persistToHistory: false,
|
||||
allowHistorySelection: false,
|
||||
allowExistingTxInput: false,
|
||||
uploadTransport: 'turbo',
|
||||
dialogTitle: 'Сделать слепок блокчейна',
|
||||
uploadButtonLabel: 'Сделать слепок',
|
||||
initialFile: snapshotFile.file,
|
||||
initialSha256: snapshotSha256,
|
||||
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
|
||||
fixedFile: true,
|
||||
autoOpenFileDialog: false,
|
||||
shineType: 'blockchain-snapshot',
|
||||
extraUploadTags: [
|
||||
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
|
||||
{ name: 'SHiNE-Login', value: login },
|
||||
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
|
||||
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
|
||||
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
|
||||
],
|
||||
});
|
||||
if (modeToken !== activeModeToken) return;
|
||||
if (!uploaded?.ar) {
|
||||
setStatus('Загрузка слепка отменена.');
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredBlockchainSnapshot(login, {
|
||||
blockchainName: snapshotFile.blockchainName,
|
||||
txId: String(uploaded.ar || '').trim(),
|
||||
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
|
||||
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
|
||||
usedBytes: Number(serverState.sizeBytes || 0),
|
||||
lastBlockNumber: Number(serverState.lastNumber),
|
||||
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
|
||||
uploadedAtMs: Date.now(),
|
||||
});
|
||||
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
|
||||
await refreshState();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
if (modeToken === activeModeToken) {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function publishSnapshotToSolana() {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const localSnapshot = readStoredBlockchainSnapshot(login);
|
||||
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
|
||||
const [usage, serverState, signing] = await Promise.all([
|
||||
getShineBlockchainUsage({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
}),
|
||||
fetchServerBlockchainState(),
|
||||
resolveWalletSigningMaterial(),
|
||||
]);
|
||||
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
|
||||
throw new Error('Локальный слепок относится к другому блокчейну.');
|
||||
}
|
||||
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|
||||
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|
||||
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
|
||||
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
|
||||
}
|
||||
|
||||
const result = await updateShineUserPdaOnSolana({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
||||
additionalLimitBytes: 0n,
|
||||
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||||
nextLastBlockNumber: serverState.lastNumber,
|
||||
nextLastBlockHashHex: serverState.lastHash,
|
||||
nextArweaveTxId: localSnapshot.txId,
|
||||
});
|
||||
if (modeToken !== activeModeToken) return;
|
||||
saveStoredBlockchainSnapshot(login, {
|
||||
...localSnapshot,
|
||||
solanaUpdatedAtMs: Date.now(),
|
||||
solanaSignature: String(result.signature || '').trim(),
|
||||
});
|
||||
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
|
||||
await refreshState();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
if (modeToken === activeModeToken) {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
void refreshState();
|
||||
});
|
||||
createBtn.addEventListener('click', () => {
|
||||
void createSnapshot();
|
||||
});
|
||||
publishBtn.addEventListener('click', () => {
|
||||
void publishSnapshotToSolana();
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
clearStoredBlockchainSnapshot(login);
|
||||
setStatus('Локальный слепок удалён из браузера.');
|
||||
void refreshState();
|
||||
});
|
||||
|
||||
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
|
||||
setStatus('Загрузка состояния публикации...');
|
||||
await refreshState();
|
||||
}
|
||||
|
||||
async function renderSolanaWallet() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
|
||||
+7
-41
@@ -1,8 +1,5 @@
|
||||
import { parseShineRouteParts } from './services/shine-routes.js';
|
||||
|
||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
||||
const SWIPEABLE_ROOT_PAGES = ['messages-list', 'channels-list', 'notifications-view', 'profile-view'];
|
||||
const lastVisitedRouteByRoot = new Map();
|
||||
let previousTrackedPath = '';
|
||||
let currentTrackedPath = String(window.location.pathname || '').trim() || '/';
|
||||
const PRETTY_PATHS = new Map([
|
||||
@@ -365,12 +362,6 @@ export function navigate(path) {
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function normalizeCurrentPath() {
|
||||
return String(window.location.pathname || '')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function toPrettyPath(path) {
|
||||
const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!raw) return '';
|
||||
@@ -387,7 +378,13 @@ export function navigateBack() {
|
||||
}
|
||||
|
||||
export function resolveToolbarActive(pageId) {
|
||||
if (ROOT_PAGES.includes(pageId)) return pageId;
|
||||
if (
|
||||
pageId === 'messages-list'
|
||||
|| pageId === 'channels-list'
|
||||
|| pageId === 'network-view'
|
||||
|| pageId === 'notifications-view'
|
||||
|| pageId === 'profile-view'
|
||||
) return pageId;
|
||||
if (
|
||||
pageId === 'profile-edit-view' ||
|
||||
pageId === 'wallet-view' ||
|
||||
@@ -416,37 +413,6 @@ export function resolveToolbarActive(pageId) {
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
export function rememberToolbarRoute(pageId, explicitPath = '') {
|
||||
const rootPageId = resolveToolbarActive(pageId);
|
||||
if (!ROOT_PAGES.includes(rootPageId)) return;
|
||||
const cleanPath = String(explicitPath || normalizeCurrentPath()).trim();
|
||||
lastVisitedRouteByRoot.set(rootPageId, cleanPath || toPrettyPath(rootPageId) || rootPageId);
|
||||
}
|
||||
|
||||
export function getToolbarNavigationTarget(pageId) {
|
||||
const rootPageId = resolveToolbarActive(pageId);
|
||||
return lastVisitedRouteByRoot.get(rootPageId) || toPrettyPath(rootPageId) || rootPageId;
|
||||
}
|
||||
|
||||
export function resetRememberedToolbarRoutes() {
|
||||
lastVisitedRouteByRoot.clear();
|
||||
previousTrackedPath = '';
|
||||
currentTrackedPath = String(window.location.pathname || '').trim() || '/';
|
||||
}
|
||||
|
||||
export function getSwipeNavigationTarget(currentPageId, direction) {
|
||||
const rootPageId = resolveToolbarActive(currentPageId);
|
||||
const index = SWIPEABLE_ROOT_PAGES.indexOf(rootPageId);
|
||||
if (index === -1) return '';
|
||||
|
||||
const step = direction === 'left' ? 1 : direction === 'right' ? -1 : 0;
|
||||
if (!step) return '';
|
||||
|
||||
const nextIndex = index + step;
|
||||
if (nextIndex < 0 || nextIndex >= SWIPEABLE_ROOT_PAGES.length) return '';
|
||||
return getToolbarNavigationTarget(SWIPEABLE_ROOT_PAGES[nextIndex]);
|
||||
}
|
||||
|
||||
export function syncTrackedRouteHistory(pathname = '') {
|
||||
const nextPath = String(pathname || '').trim() || '/';
|
||||
if (nextPath === currentTrackedPath) return;
|
||||
|
||||
@@ -153,6 +153,10 @@ function makeClientPlatform() {
|
||||
return 'Web';
|
||||
}
|
||||
|
||||
function escapeUserSettingPart(value = '') {
|
||||
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function hexToBytes(hex) {
|
||||
const clean = String(hex || '').trim().toLowerCase();
|
||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||
@@ -1022,6 +1026,18 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async resolveCanonicalDisplayLogin(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return '';
|
||||
try {
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const canonicalLogin = String(user?.login || '').trim();
|
||||
return canonicalLogin || cleanLogin;
|
||||
} catch {
|
||||
return cleanLogin;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoginFree(login) {
|
||||
const payload = await this.getUser(login);
|
||||
return payload.exists !== true;
|
||||
@@ -1127,8 +1143,10 @@ export class AuthService {
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
login: canonicalLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
sessionMaterial: {
|
||||
@@ -1213,8 +1231,10 @@ export class AuthService {
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
|
||||
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
login: canonicalLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
sessionKey: cleanSessionKey,
|
||||
@@ -1287,8 +1307,10 @@ export class AuthService {
|
||||
const storagePwd = loginResp?.payload?.storagePwd;
|
||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
login: canonicalLogin,
|
||||
sessionId: targetSessionId,
|
||||
storagePwd,
|
||||
};
|
||||
@@ -2476,6 +2498,10 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
normalizeDmLogin(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async buildSignedDmBlock({
|
||||
signerLogin,
|
||||
fromLogin,
|
||||
@@ -2488,9 +2514,9 @@ export class AuthService {
|
||||
reencryptedAtMs = 0,
|
||||
bodyBytes = new Uint8Array(0),
|
||||
}) {
|
||||
const cleanSignerLogin = String(signerLogin || '').trim();
|
||||
const cleanFromLogin = String(fromLogin || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
|
||||
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
|
||||
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||
@@ -2590,8 +2616,8 @@ export class AuthService {
|
||||
revisionTimeMs = 0,
|
||||
reencryptedAtMs = 0,
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanFromLogin = this.normalizeDmLogin(login);
|
||||
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||
const cleanText = String(text || '');
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
@@ -2674,8 +2700,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPeerLogin = String(toLogin || '').trim();
|
||||
const cleanLogin = this.normalizeDmLogin(login);
|
||||
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
@@ -2706,14 +2732,23 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
||||
const cleanLogin = this.normalizeDmLogin(login);
|
||||
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
||||
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
|
||||
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
|
||||
const payload = buildReadReceiptPayloadBytes({
|
||||
refToLogin: cleanRefToLogin,
|
||||
refFromLogin: cleanRefFromLogin,
|
||||
refTimeMs,
|
||||
refNonce,
|
||||
});
|
||||
|
||||
const type3 = await this.buildSignedDmBlock({
|
||||
signerLogin: login,
|
||||
fromLogin: login,
|
||||
toLogin,
|
||||
signerLogin: cleanLogin,
|
||||
fromLogin: cleanLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
@@ -2721,9 +2756,9 @@ export class AuthService {
|
||||
bodyBytes: payload,
|
||||
});
|
||||
const type4 = await this.buildSignedDmBlock({
|
||||
signerLogin: login,
|
||||
fromLogin: login,
|
||||
toLogin,
|
||||
signerLogin: cleanLogin,
|
||||
fromLogin: cleanLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
@@ -2737,8 +2772,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPeerLogin = String(toLogin || '').trim();
|
||||
const cleanLogin = this.normalizeDmLogin(login);
|
||||
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
@@ -2827,6 +2862,14 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getNotifications(limit = 50) {
|
||||
const payload = {};
|
||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||
const response = await this.ws.request('GetNotifications', payload);
|
||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getUserConnectionsGraph(login) {
|
||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||
@@ -2867,6 +2910,59 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async upsertUserSetting({
|
||||
login,
|
||||
settingType,
|
||||
settingKey,
|
||||
timeMs,
|
||||
valueText = '',
|
||||
valueNum = 0,
|
||||
storagePwd,
|
||||
syncDelivery = false,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanSettingKey = String(settingKey || '').trim();
|
||||
const cleanValueText = String(valueText ?? '');
|
||||
const cleanTimeMs = Number(timeMs);
|
||||
const cleanSettingType = Number(settingType);
|
||||
const cleanValueNum = Number(valueNum ?? 0);
|
||||
if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey');
|
||||
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs');
|
||||
if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.');
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
||||
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
||||
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
||||
|
||||
const preimage = [
|
||||
'SHiNe/UserSettings:',
|
||||
escapeUserSettingPart(cleanLogin),
|
||||
String(cleanSettingType),
|
||||
escapeUserSettingPart(cleanSettingKey),
|
||||
String(Math.trunc(cleanTimeMs)),
|
||||
escapeUserSettingPart(cleanValueText),
|
||||
String(Math.trunc(cleanValueNum)),
|
||||
].join('|');
|
||||
const signature = await signBase64(privateKey, preimage);
|
||||
|
||||
const response = await this.ws.request('UpsertUserSetting', {
|
||||
login: cleanLogin,
|
||||
setting_type: Math.trunc(cleanSettingType),
|
||||
setting_key: cleanSettingKey,
|
||||
time_ms: Math.trunc(cleanTimeMs),
|
||||
value_text: cleanValueText,
|
||||
value_num: Math.trunc(cleanValueNum),
|
||||
client_key: clientKey,
|
||||
signature,
|
||||
sync_delivery: !!syncDelivery,
|
||||
});
|
||||
if (response.status !== 200) throw opError('UpsertUserSetting', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getTestFreeAvatarQuota() {
|
||||
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
|
||||
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
|
||||
|
||||
@@ -8,6 +8,10 @@ const DB_VERSION = 1;
|
||||
const STORE_SECRETS = 'encrypted-secrets';
|
||||
const STORE_SESSIONS = 'session-keys';
|
||||
|
||||
function normalizeLoginStorageKey(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
@@ -50,15 +54,20 @@ async function get(storeName, key) {
|
||||
|
||||
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
|
||||
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
|
||||
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||
await put(STORE_SECRETS, {
|
||||
login,
|
||||
login: normalizedLogin,
|
||||
encrypted,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEncryptedUserSecrets(login, storagePwd) {
|
||||
const row = await get(STORE_SECRETS, login);
|
||||
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||
let row = await get(STORE_SECRETS, normalizedLogin);
|
||||
if (!row?.encrypted && normalizedLogin !== String(login || '').trim()) {
|
||||
row = await get(STORE_SECRETS, String(login || '').trim());
|
||||
}
|
||||
if (!row?.encrypted) {
|
||||
throw new Error('На устройстве нет сохранённых ключей для этого логина');
|
||||
}
|
||||
@@ -80,15 +89,19 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
|
||||
}
|
||||
|
||||
export async function saveSessionMaterial(login, material) {
|
||||
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||
await put(STORE_SESSIONS, {
|
||||
login,
|
||||
login: normalizedLogin,
|
||||
...material,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSessionMaterial(login) {
|
||||
return get(STORE_SESSIONS, login);
|
||||
const normalizedLogin = normalizeLoginStorageKey(login);
|
||||
const row = await get(STORE_SESSIONS, normalizedLogin);
|
||||
if (row || normalizedLogin === String(login || '').trim()) return row;
|
||||
return get(STORE_SESSIONS, String(login || '').trim());
|
||||
}
|
||||
|
||||
export async function clearClientAuthData() {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const SNAPSHOT_STORAGE_KEY = 'shine-ui-blockchain-snapshot-v1';
|
||||
|
||||
function normalizeLogin(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function storageBucketKey(login) {
|
||||
return normalizeLogin(login) || 'anonymous';
|
||||
}
|
||||
|
||||
function decodeBase64ToBytes(base64) {
|
||||
const raw = String(base64 || '').trim();
|
||||
if (!raw) return new Uint8Array();
|
||||
const bin = atob(raw);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function readStorageMap() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorageMap(value) {
|
||||
try {
|
||||
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(value || {}));
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredBlockchainSnapshot(login) {
|
||||
const key = storageBucketKey(login);
|
||||
const map = readStorageMap();
|
||||
const value = map?.[key];
|
||||
return value && typeof value === 'object' ? value : null;
|
||||
}
|
||||
|
||||
export function saveStoredBlockchainSnapshot(login, snapshot) {
|
||||
const key = storageBucketKey(login);
|
||||
const map = readStorageMap();
|
||||
map[key] = {
|
||||
...snapshot,
|
||||
savedAtMs: Date.now(),
|
||||
};
|
||||
writeStorageMap(map);
|
||||
return map[key];
|
||||
}
|
||||
|
||||
export function clearStoredBlockchainSnapshot(login) {
|
||||
const key = storageBucketKey(login);
|
||||
const map = readStorageMap();
|
||||
delete map[key];
|
||||
writeStorageMap(map);
|
||||
}
|
||||
|
||||
export async function buildBlockchainSnapshotFile({
|
||||
authService,
|
||||
login,
|
||||
blockchainName,
|
||||
lastBlockNumber,
|
||||
} = {}) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
const maxBlockNumber = Number(lastBlockNumber);
|
||||
if (!authService?.ws?.request) throw new Error('Сервис сервера недоступен.');
|
||||
if (!cleanLogin) throw new Error('Не указан логин.');
|
||||
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна.');
|
||||
if (!Number.isFinite(maxBlockNumber) || maxBlockNumber < 0) {
|
||||
throw new Error('На сервере нет блоков для слепка.');
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let totalBytes = 0;
|
||||
for (let blockNumber = 0; blockNumber <= maxBlockNumber; blockNumber += 1) {
|
||||
const response = await authService.ws.request('GetBlockchainBlock', {
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber,
|
||||
});
|
||||
if (response?.status !== 200) {
|
||||
const message = String(response?.payload?.message || response?.message || 'Не удалось получить блок.');
|
||||
throw new Error(`Не удалось скачать блок ${blockNumber}: ${message}`);
|
||||
}
|
||||
const blockBytes = decodeBase64ToBytes(response?.payload?.blockBytesB64 || '');
|
||||
parts.push(blockBytes);
|
||||
totalBytes += blockBytes.length;
|
||||
}
|
||||
|
||||
const file = new File(parts, `${cleanBlockchainName}.shine-blockchain`, {
|
||||
type: 'application/octet-stream',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
file,
|
||||
blockCount: maxBlockNumber + 1,
|
||||
totalBytes,
|
||||
lastBlockNumber: maxBlockNumber,
|
||||
login: cleanLogin,
|
||||
blockchainName: cleanBlockchainName,
|
||||
};
|
||||
}
|
||||
@@ -721,6 +721,8 @@ export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
|
||||
paidLimitBytes: bch.paidLimitBytes,
|
||||
usedBytes: bch.usedBytes,
|
||||
leftBytes,
|
||||
blockchainName: String(bch.blockchainName || ''),
|
||||
arweaveTxId: String(bch.arweaveTxId || ''),
|
||||
lastBlockNumber: bch.lastBlockNumber,
|
||||
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
@@ -762,9 +764,10 @@ async function attachSolanaLogs(error, connection) {
|
||||
}
|
||||
|
||||
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
||||
const rawLogin = String(login || '').trim();
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
const endpoint = String(solanaEndpoint || '').trim();
|
||||
if (!cleanLogin) throw new Error('Не указан логин');
|
||||
if (!rawLogin || !cleanLogin) throw new Error('Не указан логин');
|
||||
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
||||
|
||||
const solana = await loadSolanaLib();
|
||||
@@ -791,6 +794,7 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
||||
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
||||
|
||||
return {
|
||||
rawLogin,
|
||||
cleanLogin,
|
||||
endpoint,
|
||||
solana,
|
||||
@@ -832,18 +836,19 @@ async function createShineUserPdaOnSolana({
|
||||
}
|
||||
|
||||
const cleanLogin = ctx.cleanLogin;
|
||||
const displayLogin = ctx.rawLogin;
|
||||
const cleanPromoCode = String(promoCode || '').trim();
|
||||
const blockchainName = `${cleanLogin}-001`;
|
||||
const zeroHash32 = new Uint8Array(32);
|
||||
const createdAtMs = BigInt(Date.now());
|
||||
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
|
||||
|
||||
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
|
||||
const lastBlockStateBytes = buildLastBlockStateBytes(displayLogin, blockchainName, 0, zeroHash32, 0n);
|
||||
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
||||
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
|
||||
|
||||
const initialState = createPdaState({
|
||||
login: cleanLogin,
|
||||
login: displayLogin,
|
||||
createdAtMs,
|
||||
updatedAtMs: createdAtMs,
|
||||
recordNumber: 0,
|
||||
@@ -910,7 +915,7 @@ async function createShineUserPdaOnSolana({
|
||||
});
|
||||
}
|
||||
const ixData = serializeCreateUserPdaArgs({
|
||||
login: cleanLogin,
|
||||
login: displayLogin,
|
||||
recoveryKey32: ctx.recoveryKey32,
|
||||
rootKey32: ctx.rootKey32,
|
||||
createdAtMs,
|
||||
@@ -1028,6 +1033,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
nextUsedBytes,
|
||||
nextLastBlockNumber,
|
||||
nextLastBlockHashHex,
|
||||
nextArweaveTxId,
|
||||
serverProfile,
|
||||
accessServers,
|
||||
trustedCount,
|
||||
@@ -1043,6 +1049,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
||||
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
||||
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
|
||||
const effectiveArweaveTxId = nextArweaveTxId == null ? currentBch.arweaveTxId : String(nextArweaveTxId || '').trim();
|
||||
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
||||
|
||||
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
||||
@@ -1060,7 +1067,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
|
||||
|
||||
const lastBlockStateBytes = buildLastBlockStateBytes(
|
||||
cleanLogin,
|
||||
current.login,
|
||||
currentBch.blockchainName,
|
||||
effectiveLastNum,
|
||||
effectiveLastHash,
|
||||
@@ -1085,7 +1092,10 @@ export async function updateShineUserPdaOnSolana({
|
||||
const updatedAtMs = BigInt(Date.now());
|
||||
const newPaid = currentBch.paidLimitBytes + addLimit;
|
||||
const newRecordNumber = current.recordNumber + 1;
|
||||
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
|
||||
// Для prev_hash нужно хэшировать точную unsigned-часть текущей PDA,
|
||||
// а не пересобирать её из распарсенного состояния: иначе можно получить
|
||||
// несовпадение байт и InvalidPrevHash в on-chain программе.
|
||||
const prevHash = await sha256Bytes(current.unsignedBytes || serializeUnsignedRecordFromState(current));
|
||||
|
||||
const nextServerProfile = serverProfile
|
||||
? {
|
||||
@@ -1097,7 +1107,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
: current.serverProfile;
|
||||
|
||||
const nextState = createPdaState({
|
||||
login: cleanLogin,
|
||||
login: current.login,
|
||||
createdAtMs: current.createdAtMs,
|
||||
updatedAtMs,
|
||||
recordNumber: newRecordNumber,
|
||||
@@ -1113,7 +1123,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
lastBlockNumber: effectiveLastNum,
|
||||
lastBlockHash: effectiveLastHash,
|
||||
lastBlockSignature: lastBlockSig64,
|
||||
arweaveTxId: currentBch.arweaveTxId,
|
||||
arweaveTxId: effectiveArweaveTxId,
|
||||
}),
|
||||
isServer: Boolean(nextServerProfile),
|
||||
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
|
||||
@@ -1131,7 +1141,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
|
||||
|
||||
const ixData = serializeUpdateUserPdaArgs({
|
||||
login: cleanLogin,
|
||||
login: current.login,
|
||||
recoveryKey32: current.recoveryKey,
|
||||
rootKey32: current.rootKey,
|
||||
createdAtMs: current.createdAtMs,
|
||||
@@ -1146,7 +1156,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
lastBlockNumber: effectiveLastNum,
|
||||
lastBlockHash32: effectiveLastHash,
|
||||
lastBlockSignature64: lastBlockSig64,
|
||||
arweaveTxId: currentBch.arweaveTxId,
|
||||
arweaveTxId: effectiveArweaveTxId,
|
||||
isServer: nextState.isServer,
|
||||
addressFormatType: nextState.addressFormatType,
|
||||
addressFormatVersion: nextState.addressFormatVersion,
|
||||
@@ -1205,6 +1215,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
|
||||
lastBlockNumber: effectiveLastNum,
|
||||
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||
arweaveTxId: effectiveArweaveTxId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/*
|
||||
* Единый визуальный язык кнопок основного приложения:
|
||||
* белое содержимое, без рамок и самостоятельной подложки.
|
||||
*
|
||||
* Исключения:
|
||||
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
||||
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root a.primary-btn,
|
||||
:root a.secondary-btn,
|
||||
:root a.destructive-btn,
|
||||
:root a.ghost-btn,
|
||||
:root a.icon-btn,
|
||||
:root a.text-btn {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root a.primary-btn:hover,
|
||||
:root a.secondary-btn:hover,
|
||||
:root a.destructive-btn:hover,
|
||||
:root a.ghost-btn:hover,
|
||||
:root a.icon-btn:hover,
|
||||
:root a.text-btn:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
||||
* Эффект существует только пока кнопка физически нажата.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root a.primary-btn:active,
|
||||
:root a.secondary-btn:active,
|
||||
:root a.destructive-btn:active,
|
||||
:root a.ghost-btn:active,
|
||||
:root a.icon-btn:active,
|
||||
:root a.text-btn:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
||||
:root a.primary-btn[aria-disabled='true'],
|
||||
:root a.secondary-btn[aria-disabled='true'],
|
||||
:root a.destructive-btn[aria-disabled='true'],
|
||||
:root a.ghost-btn[aria-disabled='true'],
|
||||
:root a.icon-btn[aria-disabled='true'],
|
||||
:root a.text-btn[aria-disabled='true'] {
|
||||
color: rgba(255, 255, 255, 0.42) !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
||||
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
||||
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
||||
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
||||
*/
|
||||
:root .toolbar-btn {
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
:root .toolbar-btn:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
background: rgba(0, 0, 0, 0.14) !important;
|
||||
box-shadow:
|
||||
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root a.primary-btn:focus-visible,
|
||||
:root a.secondary-btn:focus-visible,
|
||||
:root a.destructive-btn:focus-visible,
|
||||
:root a.ghost-btn:focus-visible,
|
||||
:root a.icon-btn:focus-visible,
|
||||
:root a.text-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Уведомления: «Ответы / События».
|
||||
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
||||
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
||||
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.18) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||
transform: translateY(1px) scale(0.965) !important;
|
||||
filter: brightness(0.88) !important;
|
||||
}
|
||||
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
||||
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
filter: brightness(0.9) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
||||
* Это правило намеренно расположено после глобального белого button-rule. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: var(--app-topbar-gold) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
||||
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||
}
|
||||
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
||||
color: #FFFFFF !important;
|
||||
outline: none !important;
|
||||
filter:
|
||||
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
||||
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
||||
}
|
||||
|
||||
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
||||
:root .dm-chat-input button.dm-emoji-btn,
|
||||
:root .dm-chat-input button.dm-send-btn,
|
||||
:root .dm-chat-input button.dm-edit-banner__close,
|
||||
:root .dm-chat-input button.dm-emoji-btn:hover,
|
||||
:root .dm-chat-input button.dm-send-btn:hover,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
||||
:root .dm-chat-input button.dm-emoji-btn:focus,
|
||||
:root .dm-chat-input button.dm-send-btn:focus,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
||||
}
|
||||
|
||||
/* ===== Dropdown menu polish: persistent overflow press + text-only glowing rows ===== */
|
||||
/* Menu rows stay visually transparent in every interaction state. The menu panel itself
|
||||
* remains translucent, while each action is represented only by its glowing label. */
|
||||
:root .dm-head-menu-item,
|
||||
:root .dm-head-menu-item:hover,
|
||||
:root .dm-head-menu-item:focus,
|
||||
:root .dm-head-menu-item:focus-visible,
|
||||
:root .dm-head-menu-item:active,
|
||||
:root .channel-menu-item,
|
||||
:root .channel-menu-item:hover,
|
||||
:root .channel-menu-item:focus,
|
||||
:root .channel-menu-item:focus-visible,
|
||||
:root .channel-menu-item:active,
|
||||
:root .channel-menu-item.destructive,
|
||||
:root .channel-menu-item.destructive:hover,
|
||||
:root .channel-menu-item.destructive:focus-visible,
|
||||
:root .channel-menu-item.destructive:active {
|
||||
color: #F7FBFF !important;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.34)) !important;
|
||||
}
|
||||
|
||||
/* Hover/focus is expressed only by a slightly stronger glyph glow, never a row plate. */
|
||||
:root .dm-head-menu-item:hover,
|
||||
:root .dm-head-menu-item:focus-visible,
|
||||
:root .channel-menu-item:hover,
|
||||
:root .channel-menu-item:focus-visible {
|
||||
color: #FFFFFF !important;
|
||||
text-shadow:
|
||||
0 0 6px rgba(110, 205, 255, 0.86),
|
||||
0 0 15px rgba(72, 145, 255, 0.46) !important;
|
||||
filter: drop-shadow(0 0 5px rgba(92, 190, 255, 0.46)) !important;
|
||||
}
|
||||
|
||||
/* Keep the overflow trigger visibly pressed for exactly as long as its menu is open. */
|
||||
:root .topbar-slot .dm-head-menu-btn[aria-expanded='true'],
|
||||
:root .topbar-slot .channels-top-more-btn[aria-expanded='true'],
|
||||
:root .topbar-slot .network-top-more-btn[aria-expanded='true'],
|
||||
:root .network-header-overlay .network-top-more-btn[aria-expanded='true'] {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Authoritative open-menu trigger state (2026-08-23) =====
|
||||
* A dedicated JS class, not :active/:focus, owns the pressed state while a menu is open.
|
||||
* Kept at the very end of the stylesheet so older compatibility rules cannot cancel it. */
|
||||
:root .dm-head-menu-btn.menu-open-pressed,
|
||||
:root .channels-top-more-btn.menu-open-pressed,
|
||||
:root .network-top-more-btn.menu-open-pressed,
|
||||
:root .topbar-slot .dm-head-menu-btn.menu-open-pressed,
|
||||
:root .topbar-slot .channels-top-more-btn.menu-open-pressed,
|
||||
:root .topbar-slot .network-top-more-btn.menu-open-pressed,
|
||||
:root .network-header-overlay .network-top-more-btn.menu-open-pressed {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(0, 0, 0, 0.14) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.62),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.09) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.76),
|
||||
0 0 12px rgba(72, 145, 255, 0.38) !important;
|
||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ===== Final dropdown behavior: truly transparent menu + persistent pressed trigger =====
|
||||
* The dropdown itself has no visual surface. Only the global dim layer affects content
|
||||
* behind it. The overflow trigger gets its own inset pseudo-layer so the pressed state
|
||||
* cannot be cancelled by the generic button reset after pointer release. */
|
||||
:root .dm-head-menu,
|
||||
:root .dm-head-menu--portal,
|
||||
:root .channel-menu-wrap,
|
||||
:root .channel-menu-wrap--portal {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
:root .dm-head-menu::before,
|
||||
:root .dm-head-menu::after,
|
||||
:root .channel-menu-wrap::before,
|
||||
:root .channel-menu-wrap::after,
|
||||
:root .channel-menu-wrap--portal::before,
|
||||
:root .channel-menu-wrap--portal::after {
|
||||
content: none !important;
|
||||
display: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* No separator or hover plate: the dropdown is literally labels over faded content. */
|
||||
:root .channel-menu-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root .dm-head-menu-item,
|
||||
:root .dm-head-menu-item:hover,
|
||||
:root .dm-head-menu-item:focus,
|
||||
:root .dm-head-menu-item:focus-visible,
|
||||
:root .dm-head-menu-item:active,
|
||||
:root .channel-menu-item,
|
||||
:root .channel-menu-item:hover,
|
||||
:root .channel-menu-item:focus,
|
||||
:root .channel-menu-item:focus-visible,
|
||||
:root .channel-menu-item:active,
|
||||
:root .channel-menu-item.destructive,
|
||||
:root .channel-menu-item.destructive:hover,
|
||||
:root .channel-menu-item.destructive:focus-visible,
|
||||
:root .channel-menu-item.destructive:active {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Keep the channels backdrop out of the toolbar itself, matching the other pages. */
|
||||
:root .channels-menu-overlay {
|
||||
top: calc(var(--topbar-height, 64px) + var(--call-minimized-bar-height, 0px)) !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
background: rgba(0, 0, 0, 0.49) !important;
|
||||
}
|
||||
|
||||
/* Authoritative pressed well. It is independent from :active/:focus and survives until
|
||||
* JS removes menu-open-pressed / aria-expanded=true on actual menu close. */
|
||||
:root .dm-head-menu-btn,
|
||||
:root .channels-top-more-btn,
|
||||
:root .network-top-more-btn {
|
||||
position: relative !important;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
:root .dm-head-menu-btn.menu-open-pressed::before,
|
||||
:root .channels-top-more-btn.menu-open-pressed::before,
|
||||
:root .network-top-more-btn.menu-open-pressed::before,
|
||||
:root .dm-head-menu-btn[aria-expanded='true']::before,
|
||||
:root .channels-top-more-btn[aria-expanded='true']::before,
|
||||
:root .network-top-more-btn[aria-expanded='true']::before {
|
||||
content: '' !important;
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
inset: 2px !important;
|
||||
z-index: 0 !important;
|
||||
pointer-events: none !important;
|
||||
border: 0 !important;
|
||||
border-radius: 10px !important;
|
||||
background: rgba(0, 0, 0, 0.18) !important;
|
||||
background-image: none !important;
|
||||
box-shadow:
|
||||
inset 0 4px 10px rgba(0, 0, 0, 0.72),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
:root .dm-head-menu-btn.menu-open-pressed,
|
||||
:root .channels-top-more-btn.menu-open-pressed,
|
||||
:root .network-top-more-btn.menu-open-pressed,
|
||||
:root .dm-head-menu-btn[aria-expanded='true'],
|
||||
:root .channels-top-more-btn[aria-expanded='true'],
|
||||
:root .network-top-more-btn[aria-expanded='true'] {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
filter: brightness(0.90) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
:root .dm-head-menu-btn.menu-open-pressed > *,
|
||||
:root .channels-top-more-btn.menu-open-pressed > *,
|
||||
:root .network-top-more-btn.menu-open-pressed > *,
|
||||
:root .dm-head-menu-btn[aria-expanded='true'] > *,
|
||||
:root .channels-top-more-btn[aria-expanded='true'] > *,
|
||||
:root .network-top-more-btn[aria-expanded='true'] > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ===== Definitive open overflow state + lighter menu dim (2026-08-23) =====
|
||||
* The generic button reset above has specificity 0,4,1, so the persistent
|
||||
* menu state must beat it directly on the real button (not on ::before).
|
||||
* Keep this visually identical to the physical :active press until JS closes the menu. */
|
||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
|
||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
|
||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
|
||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
|
||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* The menu itself is still fully transparent; only the content below is dimmed.
|
||||
* Previous alpha was ~0.49. Reduce it by about 20% relatively -> ~0.39. */
|
||||
:root body .ui-menu-dim-layer,
|
||||
:root body .dm-head-menu-backdrop,
|
||||
:root body .channels-menu-overlay {
|
||||
background: rgba(0, 0, 0, 0.39) !important;
|
||||
}
|
||||
|
||||
/* ===== Round persistent overflow press + subtle background depth (2026-08-23) =====
|
||||
* The open overflow control is a circular recessed well. While a topbar menu is
|
||||
* present, only the content plane below the toolbar recedes slightly; the toolbar
|
||||
* and the fully transparent menu stay at full size. */
|
||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
|
||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
|
||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
|
||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
|
||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
|
||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed::before,
|
||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true']::before,
|
||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed::before,
|
||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true']::before,
|
||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed::before,
|
||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true']::before,
|
||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed::before,
|
||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true']::before {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* Depth cue: the feed/graph plane gently recedes while a topbar overflow menu is open.
|
||||
* Keep the scale small enough that opening the menu does not feel like a page jump. */
|
||||
@supports selector(body:has(*)) {
|
||||
:root body .app-shell > .screen-content {
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 180ms cubic-bezier(0.22, 0.61, 0.36, 1), filter 180ms ease;
|
||||
}
|
||||
|
||||
:root body:has(> .ui-menu-dim-layer) .app-shell > .screen-content,
|
||||
:root body:has(> #modal-root #channels-top-menu-overlay) .app-shell > .screen-content {
|
||||
transform: scale(0.975);
|
||||
filter: brightness(0.97) saturate(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root body .app-shell > .screen-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
+1980
-25
File diff suppressed because it is too large
Load Diff
+55
-142
@@ -2,7 +2,7 @@ body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #05070A;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -22,12 +22,11 @@ body::before {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(100vw, 430px);
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||
height: var(--app-viewport-height, 100vh);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
top: var(--app-viewport-offset-top, 0px);
|
||||
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
|
||||
transform: translateX(-50%);
|
||||
--call-minimized-bar-height: 0px;
|
||||
--topbar-height: 0px;
|
||||
@@ -35,8 +34,8 @@ body::before {
|
||||
--toolbar-height: 78px;
|
||||
--keyboard-offset: 0px;
|
||||
background: transparent;
|
||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -59,124 +58,6 @@ body::before {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.screen-swipe-overlay {
|
||||
--swipe-overlay-opacity: 0.12;
|
||||
position: absolute;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
z-index: 8;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.screen-swipe-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 10, 20, 0.06) 0%, rgba(5, 10, 20, 0.16) 100%),
|
||||
radial-gradient(circle at center, rgba(89, 165, 255, 0.06) 0%, transparent 70%);
|
||||
opacity: var(--swipe-overlay-opacity, 0.12);
|
||||
}
|
||||
|
||||
.screen-swipe-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.screen-swipe-pane--current {
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screen-swipe-pane--target {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
filter: saturate(1.03);
|
||||
}
|
||||
|
||||
.screen-swipe-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.screen-swipe-slot--topbar {
|
||||
top: 0;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-swipe-slot--topbar > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-swipe-slot--content {
|
||||
top: calc(var(--topbar-height, 0px));
|
||||
bottom: var(--composer-height, 0px);
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.screen-swipe-slot--composer {
|
||||
bottom: 0;
|
||||
padding: 0 12px 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-swipe-slot--composer > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-swipe-pane--target::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(8, 14, 28, 0.05) 0%, rgba(8, 14, 28, 0.12) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-swipe-pane--left {
|
||||
box-shadow: -24px 0 34px rgba(3, 7, 18, 0.34);
|
||||
}
|
||||
|
||||
.screen-swipe-pane--right {
|
||||
box-shadow: 24px 0 34px rgba(3, 7, 18, 0.34);
|
||||
}
|
||||
|
||||
.screen-swipe-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
z-index: 3;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 251, 255, 0.68) 0%, rgba(137, 186, 255, 0.52) 48%, rgba(53, 90, 144, 0.34) 100%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 24, 42, 0.08),
|
||||
0 0 18px rgba(80, 154, 255, 0.22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-shell--swiping .toolbar-slot {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topbar-slot--swipe-hidden,
|
||||
.screen-content--swipe-hidden,
|
||||
.composer-slot--swipe-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.screen-content.no-app-chrome {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
bottom: 0;
|
||||
@@ -212,10 +93,11 @@ body::before {
|
||||
|
||||
.composer-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
left: 50%;
|
||||
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
padding: 0 12px 8px;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -233,20 +115,42 @@ body::before {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .screen-content {
|
||||
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)));
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .composer-slot {
|
||||
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px));
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.chat-keyboard-open .toolbar-slot {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
body.chat-topbar-overlay .topbar-slot {
|
||||
position: fixed;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: min(100vw, 430px);
|
||||
margin: 0 auto;
|
||||
transform: none;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
body.chat-topbar-overlay .screen-content {
|
||||
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
|
||||
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
|
||||
}
|
||||
|
||||
body.chat-topbar-overlay .composer-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
|
||||
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
|
||||
visualViewport, а область сообщений заканчивается прямо над composer. */
|
||||
.app-shell.keyboard-open .toolbar-slot {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
|
||||
bottom: var(--keyboard-offset, 0px);
|
||||
}
|
||||
|
||||
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
|
||||
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
|
||||
}
|
||||
|
||||
.connection-retry-banner {
|
||||
@@ -295,3 +199,12 @@ body.chat-keyboard-open .toolbar-slot {
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
|
||||
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
}
|
||||
.fg-orb-host .fg-pngorb-init {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
||||
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||
}
|
||||
|
||||
.fg-node.is-family .node-dot {
|
||||
@@ -422,7 +422,7 @@
|
||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||
.fg-filter-bar {
|
||||
position: absolute;
|
||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
||||
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user