SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
7a5ac01d1c | ||
|
|
5e6d64e965 | ||
|
|
c425fa41aa | ||
|
|
781157299f | ||
|
|
c70f18fcf4 | ||
|
|
b7a869c514 | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 | ||
|
|
47a4844ce8 | ||
|
|
8741be6cba | ||
|
|
4656a03ea9 | ||
|
|
5763eb828e | ||
|
|
6a5c20a165 | ||
|
|
fac166f186 | ||
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 |
+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,24 @@ 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 int SCHEMA_VERSION_11 = 11;
|
||||
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";
|
||||
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -100,6 +114,34 @@ 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);
|
||||
currentVersion = SCHEMA_VERSION_10;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_11) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DriverManagerDbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -34,6 +35,12 @@ public final class PostgresDbController {
|
||||
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
||||
connection.setAutoCommit(true);
|
||||
});
|
||||
|
||||
try (Connection connection = this.delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PostgresDbController getInstance() {
|
||||
|
||||
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM connections_state cs
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||
LEFT JOIN %s
|
||||
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
||||
WHERE LOWER(cs.login) = LOWER(?)
|
||||
AND cs.rel_type = ?
|
||||
AND (
|
||||
LOWER(cs.to_login) = LOWER(?)
|
||||
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
|
||||
)
|
||||
LIMIT 1
|
||||
""".formatted(
|
||||
CurrentUsersSql.usersSubquery("u_login"),
|
||||
CurrentUsersSql.usersSubquery("u_bch")
|
||||
);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(3, peerLoginAnyCase);
|
||||
ps.setString(4, peerLoginAnyCase);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Base64;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public final class DmDialogStateDAO {
|
||||
private static volatile DmDialogStateDAO instance;
|
||||
|
||||
private DmDialogStateDAO() {}
|
||||
|
||||
public static DmDialogStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmDialogStateDAO.class) {
|
||||
if (instance == null) instance = new DmDialogStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void bootstrapIfEmpty(Connection c) throws SQLException {
|
||||
if (c == null) return;
|
||||
if (hasAnyRow(c)) return;
|
||||
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
for (PairItem pair : listConversationPairs(c)) {
|
||||
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
|
||||
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
refreshConversation(c, cleanOwner, cleanPeer);
|
||||
refreshConversation(c, cleanPeer, cleanOwner);
|
||||
}
|
||||
|
||||
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
if (cleanOwner.isEmpty()) return List.of();
|
||||
|
||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms,
|
||||
updated_at_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
""")) {
|
||||
ps.setString(1, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
rs.getLong("last_message_time_ms"),
|
||||
rs.getInt("unread_count"),
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
true
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String peer : contacts) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||
}
|
||||
for (String peer : closeFriends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||
}
|
||||
|
||||
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
|
||||
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
|
||||
row.ownerLogin(),
|
||||
row.peerLogin(),
|
||||
relationFlag,
|
||||
row.lastMessageBlobB64(),
|
||||
row.lastMessageTimeMs(),
|
||||
row.unreadCount(),
|
||||
row.lastReadReceiptTimeMs(),
|
||||
row.updatedAtMs(),
|
||||
row.hasDialog()
|
||||
));
|
||||
}
|
||||
|
||||
List<DialogSummary> out = new ArrayList<>(byPeer.values());
|
||||
out.sort((a, b) -> {
|
||||
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
|
||||
if (cmp != 0) return cmp;
|
||||
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
|
||||
}
|
||||
|
||||
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
|
||||
upsert(c, summary);
|
||||
if (summary.lastReadReceiptTimeMs() > 0) {
|
||||
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
|
||||
}
|
||||
}
|
||||
|
||||
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
|
||||
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
|
||||
}
|
||||
|
||||
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
|
||||
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
|
||||
|
||||
String latestSql = """
|
||||
SELECT raw_block, time_ms
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
String unreadSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM signed_messages
|
||||
WHERE LOWER(from_login) = LOWER(?)
|
||||
AND LOWER(to_login) = LOWER(?)
|
||||
AND message_type = 1
|
||||
AND time_ms > ?
|
||||
AND (read_at_ms IS NULL OR read_at_ms <= 0)
|
||||
""";
|
||||
String contentReadSql = """
|
||||
SELECT MAX(read_at_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND read_at_ms IS NOT NULL
|
||||
AND read_at_ms > 0
|
||||
""";
|
||||
String receiptWatermarkSql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (3, 4)
|
||||
""";
|
||||
|
||||
String lastMessageBlobB64 = "";
|
||||
long lastMessageTimeMs = 0L;
|
||||
int unreadCount = 0;
|
||||
long lastReadReceiptTimeMs = existingWatermark;
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
lastMessageTimeMs = rs.getLong("time_ms");
|
||||
byte[] rawBlock = rs.getBytes("raw_block");
|
||||
if (rawBlock != null && rawBlock.length > 0) {
|
||||
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
|
||||
ps.setString(1, cleanOwner);
|
||||
ps.setString(2, cleanPeer);
|
||||
ps.setString(3, cleanPeer);
|
||||
ps.setString(4, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong(1);
|
||||
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
|
||||
ps.setString(1, cleanPeer);
|
||||
ps.setString(2, cleanOwner);
|
||||
ps.setLong(3, lastReadReceiptTimeMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) unreadCount = rs.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new DialogSummary(
|
||||
cleanOwner,
|
||||
cleanPeer,
|
||||
relationFlag,
|
||||
lastMessageBlobB64,
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
lastReadReceiptTimeMs,
|
||||
System.currentTimeMillis(),
|
||||
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
|
||||
);
|
||||
}
|
||||
|
||||
private void upsert(Connection c, DialogSummary summary) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_dialog_state (
|
||||
owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
|
||||
relation_flag = EXCLUDED.relation_flag,
|
||||
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
|
||||
last_message_time_ms = EXCLUDED.last_message_time_ms,
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, summary.ownerLogin());
|
||||
ps.setString(2, summary.peerLogin());
|
||||
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
|
||||
ps.setString(4, summary.lastMessageBlobB64());
|
||||
ps.setLong(5, summary.lastMessageTimeMs());
|
||||
ps.setInt(6, summary.unreadCount());
|
||||
ps.setLong(7, summary.lastReadReceiptTimeMs());
|
||||
ps.setLong(8, summary.updatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
private boolean hasAnyRow(Connection c) throws SQLException {
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
|
||||
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
|
||||
String sql = """
|
||||
SELECT DISTINCT owner_login, peer_login
|
||||
FROM (
|
||||
SELECT
|
||||
m.target_login AS owner_login,
|
||||
CASE
|
||||
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
|
||||
ELSE m.to_login
|
||||
END AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.from_login AS owner_login, m.to_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.to_login AS owner_login, m.from_login AS peer_login
|
||||
FROM signed_messages m
|
||||
WHERE m.message_type IN (5, 6, 7, 8)
|
||||
) pairs
|
||||
WHERE owner_login IS NOT NULL
|
||||
AND peer_login IS NOT NULL
|
||||
AND BTRIM(owner_login) <> ''
|
||||
AND BTRIM(peer_login) <> ''
|
||||
AND LOWER(owner_login) <> LOWER(peer_login)
|
||||
ORDER BY owner_login, peer_login
|
||||
""";
|
||||
List<PairItem> out = new ArrayList<>();
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String ownerLogin = rs.getString("owner_login");
|
||||
String peerLogin = rs.getString("peer_login");
|
||||
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
|
||||
out.add(new PairItem(ownerLogin, peerLogin));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
|
||||
String owner = normalize(ownerLogin);
|
||||
String peer = normalize(peerLogin);
|
||||
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
|
||||
String key = normKey(peer);
|
||||
DialogSummary current = map.get(key);
|
||||
if (current == null) {
|
||||
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
|
||||
return;
|
||||
}
|
||||
String nextRelation = current.relationFlag();
|
||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "close_friend";
|
||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "contact";
|
||||
} else {
|
||||
nextRelation = normalizeRelationFlag(nextRelation);
|
||||
}
|
||||
map.put(key, new DialogSummary(
|
||||
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
|
||||
peer,
|
||||
nextRelation,
|
||||
current.lastMessageBlobB64(),
|
||||
current.lastMessageTimeMs(),
|
||||
current.unreadCount(),
|
||||
current.lastReadReceiptTimeMs(),
|
||||
current.updatedAtMs(),
|
||||
current.hasDialog() || hasDialog
|
||||
));
|
||||
}
|
||||
|
||||
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
|
||||
if (watermark <= 0) return;
|
||||
String cleanOwner = normalize(ownerLogin);
|
||||
String cleanPeer = normalize(peerLogin);
|
||||
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_messages
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN read_at_ms
|
||||
ELSE read_at_ms
|
||||
END
|
||||
WHERE (
|
||||
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
AND message_type IN (1, 2)
|
||||
AND time_ms <= ?
|
||||
""")) {
|
||||
ps.setLong(1, watermark);
|
||||
ps.setLong(2, watermark);
|
||||
ps.setString(3, cleanOwner);
|
||||
ps.setString(4, cleanPeer);
|
||||
ps.setString(5, cleanPeer);
|
||||
ps.setString(6, cleanOwner);
|
||||
ps.setLong(7, watermark);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT last_read_receipt_time_ms
|
||||
FROM dm_dialog_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(peer_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, peerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return 0L;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? 0L : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String normKey(String value) {
|
||||
return normalize(value).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeRelationFlag(String value) {
|
||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
return "none";
|
||||
}
|
||||
|
||||
public record DialogSummary(
|
||||
String ownerLogin,
|
||||
String peerLogin,
|
||||
String relationFlag,
|
||||
String lastMessageBlobB64,
|
||||
long lastMessageTimeMs,
|
||||
int unreadCount,
|
||||
long lastReadReceiptTimeMs,
|
||||
long updatedAtMs,
|
||||
boolean hasDialog
|
||||
) {}
|
||||
|
||||
private record PairItem(String ownerLogin, String peerLogin) {}
|
||||
}
|
||||
-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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
|
||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
if (status.applied()) {
|
||||
markMessageReadByReceipt(c, e);
|
||||
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
|
||||
upsertMessage(c, incoming);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,30 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
peer_login TEXT NOT NULL,
|
||||
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||
last_message_key TEXT NOT NULL DEFAULT '',
|
||||
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, peer_login)
|
||||
);
|
||||
|
||||
ALTER TABLE IF EXISTS dm_dialog_state
|
||||
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
|
||||
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 11, 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, 11, 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,
|
||||
@@ -706,6 +751,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
ON signed_message_session_delivery(session_id, delivered);
|
||||
|
||||
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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
peer_login TEXT NOT NULL,
|
||||
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||
last_message_key TEXT NOT NULL DEFAULT '',
|
||||
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, peer_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
|
||||
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
|
||||
+24
-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;
|
||||
@@ -90,11 +96,12 @@ 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 +110,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 +190,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()),
|
||||
@@ -204,6 +217,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 +279,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),
|
||||
@@ -286,6 +306,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),
|
||||
|
||||
+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) {
|
||||
|
||||
+20
-4
@@ -8,10 +8,10 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
@@ -23,14 +23,30 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
|
||||
List<DmDialogStateDAO.DialogSummary> dialogs = DmDialogStateDAO.getInstance().listInboxDialogs(c, ctx.getLogin());
|
||||
Net_ListContacts_Response resp = new Net_ListContacts_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(ctx.getLogin());
|
||||
resp.setContacts(contacts);
|
||||
resp.setDialogs(toDialogItems(dialogs));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
|
||||
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
|
||||
if (dialogs == null) return items;
|
||||
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
||||
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
||||
item.setPeerLogin(dialog.peerLogin());
|
||||
item.setRelationFlag(dialog.relationFlag());
|
||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
||||
item.setUnreadCount(dialog.unreadCount());
|
||||
item.setHasDialog(dialog.hasDialog());
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-3
@@ -7,10 +7,32 @@ import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> contacts = new ArrayList<>();
|
||||
private List<DialogItem> dialogs = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<String> getContacts() { return contacts; }
|
||||
public void setContacts(List<String> contacts) { this.contacts = contacts; }
|
||||
public List<DialogItem> getDialogs() { return dialogs; }
|
||||
public void setDialogs(List<DialogItem> dialogs) { this.dialogs = dialogs; }
|
||||
|
||||
public static class DialogItem {
|
||||
private String peerLogin;
|
||||
private String relationFlag;
|
||||
private String lastMessageBlobB64;
|
||||
private long lastMessageTimeMs;
|
||||
private int unreadCount;
|
||||
private boolean hasDialog;
|
||||
|
||||
public String getPeerLogin() { return peerLogin; }
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public String getRelationFlag() { return relationFlag; }
|
||||
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
|
||||
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
|
||||
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
||||
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
||||
public void setLastMessageTimeMs(long lastMessageTimeMs) { this.lastMessageTimeMs = lastMessageTimeMs; }
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
public boolean isHasDialog() { return hasDialog; }
|
||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||
}
|
||||
}
|
||||
|
||||
+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", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
|
||||
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 не соответствует пользователю");
|
||||
}
|
||||
|
||||
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
|
||||
if (!signatureOk) {
|
||||
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
|
||||
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
|
||||
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
|
||||
login, settingType, settingKey);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.7.0
|
||||
server.version=1.5.0
|
||||
server.version=1.6.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`;
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
| `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` | граф связей пользователя |
|
||||
@@ -63,6 +66,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 +76,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`.
|
||||
|
||||
@@ -66,11 +66,44 @@
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"contacts": ["Bob", "Kate"]
|
||||
"dialogs": [
|
||||
{
|
||||
"peerLogin": "Bob",
|
||||
"relationFlag": "close_friend",
|
||||
"lastMessageBlobB64": "U0hpTkVfRE0B...",
|
||||
"lastMessageTimeMs": 1774700000123,
|
||||
"unreadCount": 2,
|
||||
"hasDialog": true
|
||||
},
|
||||
{
|
||||
"peerLogin": "Kate",
|
||||
"relationFlag": "contact",
|
||||
"lastMessageBlobB64": "",
|
||||
"lastMessageTimeMs": 0,
|
||||
"unreadCount": 0,
|
||||
"hasDialog": false
|
||||
},
|
||||
{
|
||||
"peerLogin": "Mira",
|
||||
"relationFlag": "none",
|
||||
"lastMessageBlobB64": "U0hpTkVfRE0B...",
|
||||
"lastMessageTimeMs": 1774700000555,
|
||||
"unreadCount": 1,
|
||||
"hasDialog": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `dialogs` это серверный inbox-проекционный список диалогов;
|
||||
- `relationFlag` возвращается как `close_friend`, `contact` или `none`;
|
||||
- если один и тот же человек есть и в `contact`, и в `close_friend`, в `dialogs` он приходит как `close_friend`.
|
||||
- `lastMessageBlobB64` содержит полный signed DM block последнего контентного сообщения в base64;
|
||||
- для чатов без сообщений поле `lastMessageBlobB64` пустое.
|
||||
|
||||
---
|
||||
|
||||
## 3. `GetUserConnectionsGraph`
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
|
||||
Важно:
|
||||
|
||||
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
|
||||
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
|
||||
|
||||
## 1. `UpsertPushToken`
|
||||
|
||||
@@ -148,6 +149,12 @@
|
||||
|
||||
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||
|
||||
### Примечание
|
||||
|
||||
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
|
||||
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
|
||||
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
|
||||
|
||||
## 5. `DeleteMessage`
|
||||
|
||||
Принимает один signed DM-блок `type=5` или `type=6`.
|
||||
@@ -360,7 +367,7 @@
|
||||
|
||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
||||
- сервер не расшифровывает DM и не использует ciphertext как preview текста
|
||||
- сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
|
||||
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
|
||||
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
|
||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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`.
|
||||
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
|
||||
|
||||
## 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 в списке каналов и в канале.
|
||||
@@ -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,7 +334,24 @@
|
||||
|
||||
### 8.2. Новые методы, которые нужны
|
||||
|
||||
Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
|
||||
### 8.3. Серверный слой диалогов
|
||||
|
||||
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
|
||||
|
||||
- отдельная запись на пару `owner_login` + `peer_login`;
|
||||
- `relation_flag` со значениями `close_friend`, `contact`, `none`;
|
||||
- `last_message_blob_b64` как последний контентный signed DM block в base64;
|
||||
- `last_message_time_ms`;
|
||||
- `unread_count`;
|
||||
- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения.
|
||||
|
||||
Ключевые правила:
|
||||
|
||||
- `close_friend` всегда имеет приоритет над `contact`;
|
||||
- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад;
|
||||
- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`;
|
||||
- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции;
|
||||
- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
|
||||
|
||||
## 9. Правила валидации и применения
|
||||
|
||||
@@ -653,7 +670,6 @@ UI-следствие для клиента:
|
||||
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
||||
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
||||
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
||||
- legacy `SendDirectMessage` должен быть отключён;
|
||||
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
||||
|
||||
## 14. Что в v1 пока не входит
|
||||
|
||||
@@ -252,6 +252,15 @@ ReadReceiptBody_v1_0
|
||||
|
||||
Различается только `messageType` и формат `body`.
|
||||
|
||||
### Серверное примечание
|
||||
|
||||
Внешний байтовый формат `type=3/4` не меняется, но сервер использует такие контейнеры как вход для обновления `dm_dialog_state`:
|
||||
|
||||
- `read receipt` обновляет серверный watermark диалога;
|
||||
- `unreadCount` пересчитывается на сервере, а не только на клиенте;
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+310
-5
@@ -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,7 +12,7 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
</script>
|
||||
<script>
|
||||
(function attachBootErrorOverlay() {
|
||||
const show = (title, text) => {
|
||||
const stateKey = '__SHINE_BOOT_ERROR_STATE__';
|
||||
const menuId = 'boot-error-action-sheet';
|
||||
const escapeText = (value) => String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
const safeString = (value, maxLen = 1000) => {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
|
||||
};
|
||||
const setState = (next) => {
|
||||
try {
|
||||
window[stateKey] = next;
|
||||
} catch {}
|
||||
};
|
||||
const getState = () => {
|
||||
try {
|
||||
return window[stateKey] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const getKnownClientError = () => {
|
||||
try {
|
||||
return typeof window.__SHINE_GET_LAST_CLIENT_ERROR__ === 'function'
|
||||
? window.__SHINE_GET_LAST_CLIENT_ERROR__()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const showFeedback = (message, kind = 'success') => {
|
||||
try {
|
||||
if (typeof window.__SHINE_SHOW_TOAST__ === 'function') {
|
||||
window.__SHINE_SHOW_TOAST__(message, kind);
|
||||
} else {
|
||||
console.info(message);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const buildReport = () => {
|
||||
const state = getState() || {};
|
||||
const known = getKnownClientError();
|
||||
const viewport = {
|
||||
width: Math.round(window.innerWidth || 0),
|
||||
height: Math.round(window.innerHeight || 0),
|
||||
dpr: Number(window.devicePixelRatio || 1),
|
||||
visualWidth: Math.round(window.visualViewport?.width || 0),
|
||||
visualHeight: Math.round(window.visualViewport?.height || 0),
|
||||
visualScale: Number(window.visualViewport?.scale || 1),
|
||||
};
|
||||
const screenInfo = window.screen ? {
|
||||
width: Math.round(window.screen.width || 0),
|
||||
height: Math.round(window.screen.height || 0),
|
||||
availWidth: Math.round(window.screen.availWidth || 0),
|
||||
availHeight: Math.round(window.screen.availHeight || 0),
|
||||
pixelDepth: Number(window.screen.pixelDepth || 0),
|
||||
} : null;
|
||||
return {
|
||||
kind: safeString(state.kind || 'boot_error', 64),
|
||||
title: safeString(state.title || 'BOOT ERROR', 128),
|
||||
message: safeString(state.message || '', 500),
|
||||
stack: safeString(state.stack || '', 8000),
|
||||
sourceUrl: safeString(state.sourceUrl || '', 240),
|
||||
lineNumber: Number.isFinite(state.lineNumber) ? state.lineNumber : null,
|
||||
columnNumber: Number.isFinite(state.columnNumber) ? state.columnNumber : null,
|
||||
reasonType: safeString(state.reasonType || '', 64),
|
||||
route: safeString(window.location.hash || window.location.pathname || '', 200),
|
||||
href: safeString(window.location.href || '', 240),
|
||||
pageTitle: safeString(document.title || '', 200),
|
||||
pageVisibility: safeString(document.visibilityState || '', 32),
|
||||
userAgent: safeString(navigator.userAgent || '', 240),
|
||||
locale: safeString(navigator.language || '', 32),
|
||||
clientTs: Number.isFinite(state.clientTs) ? state.clientTs : Date.now(),
|
||||
viewport,
|
||||
screenInfo,
|
||||
lastKnownClientError: known || null,
|
||||
contextJson: safeString(JSON.stringify({
|
||||
bootState: state,
|
||||
currentRoute: window.location.hash || window.location.pathname || '',
|
||||
hasClientErrorSender: typeof window.__SHINE_SEND_CLIENT_ERROR__ === 'function',
|
||||
}), 2000),
|
||||
};
|
||||
};
|
||||
const buildSendPayload = (report) => {
|
||||
const payload = {
|
||||
kind: safeString(report?.kind || 'boot_error', 64),
|
||||
message: safeString(report?.message || report?.title || 'Неизвестная ошибка', 500),
|
||||
stack: safeString(report?.stack || '', 8000),
|
||||
sourceUrl: safeString(report?.sourceUrl || '', 240),
|
||||
lineNumber: Number.isFinite(report?.lineNumber) ? report.lineNumber : null,
|
||||
columnNumber: Number.isFinite(report?.columnNumber) ? report.columnNumber : null,
|
||||
route: safeString(report?.route || '', 200),
|
||||
href: safeString(report?.href || '', 240),
|
||||
userAgent: safeString(report?.userAgent || '', 240),
|
||||
clientTs: Number.isFinite(report?.clientTs) ? report.clientTs : Date.now(),
|
||||
requestOp: '',
|
||||
requestIdRef: '',
|
||||
contextJson: safeString(JSON.stringify({
|
||||
...report,
|
||||
contextJson: undefined,
|
||||
}), 2000),
|
||||
};
|
||||
return payload;
|
||||
};
|
||||
const shareErrorText = async (report) => {
|
||||
const text = formatReportText(report);
|
||||
if (!text) throw new Error('Текст ошибки пуст');
|
||||
if (!navigator.share) {
|
||||
throw new Error('Отправка через системное меню недоступна в этом браузере');
|
||||
}
|
||||
try {
|
||||
await navigator.share({
|
||||
title: report?.title || 'Описание ошибки',
|
||||
text,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return false;
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const formatReportText = (report) => {
|
||||
const lines = [
|
||||
`Ошибка: ${report.title || report.kind || 'unknown'}`,
|
||||
`Описание: ${report.message || '—'}`,
|
||||
`Окно: ${report.pageTitle || '—'}`,
|
||||
`Маршрут: ${report.route || '—'}`,
|
||||
`URL: ${report.href || '—'}`,
|
||||
`Видимость: ${report.pageVisibility || '—'}`,
|
||||
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
|
||||
`UA: ${report.userAgent || '—'}`,
|
||||
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
|
||||
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
|
||||
`Источник: ${report.sourceUrl || '—'}`,
|
||||
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
|
||||
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
|
||||
`Тип причины: ${report.reasonType || '—'}`,
|
||||
];
|
||||
if (report.stack) {
|
||||
lines.push('Stack:', report.stack);
|
||||
}
|
||||
if (report.lastKnownClientError) {
|
||||
lines.push('Последняя известная ошибка:', JSON.stringify(report.lastKnownClientError, null, 2));
|
||||
}
|
||||
if (report.contextJson) {
|
||||
lines.push('Контекст:', report.contextJson);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
const copyText = async (text) => {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', 'readonly');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
ta.style.pointerEvents = 'none';
|
||||
document.body.append(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
};
|
||||
const removeMenu = () => {
|
||||
try {
|
||||
document.getElementById(menuId)?.remove();
|
||||
} catch {}
|
||||
};
|
||||
const openMenu = () => {
|
||||
try {
|
||||
removeMenu();
|
||||
const report = buildReport();
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const shell = document.createElement('div');
|
||||
shell.id = menuId;
|
||||
shell.className = 'modal-shell boot-error-menu-shell';
|
||||
shell.innerHTML = `
|
||||
<div class="modal-backdrop" data-action="close"></div>
|
||||
<div class="modal-dialog boot-error-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="boot-error-menu-title" tabindex="-1">
|
||||
<div class="modal-card stack boot-error-menu-card">
|
||||
<strong class="modal-title" id="boot-error-menu-title">Описание ошибки</strong>
|
||||
<p class="meta-muted boot-error-menu-message">${escapeText(report.message || report.title || 'Ошибка')}</p>
|
||||
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
|
||||
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
|
||||
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
|
||||
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.append(shell);
|
||||
|
||||
const dialog = shell.querySelector('.modal-dialog');
|
||||
const close = () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
shell.remove();
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
shell.addEventListener('click', (event) => {
|
||||
if (event.target === shell || event.target?.dataset?.action === 'close') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="copy"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await copyText(formatReportText(report));
|
||||
showFeedback('Описание ошибки скопировано');
|
||||
} catch {
|
||||
showFeedback('Не удалось скопировать описание ошибки', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="share"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const shared = await shareErrorText(report);
|
||||
if (shared === false) return close();
|
||||
showFeedback('Текст ошибки открыт для отправки');
|
||||
} catch (error) {
|
||||
showFeedback(error?.message || 'Не удалось открыть системное меню отправки', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="send"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const sender = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
|
||||
if (typeof sender !== 'function') {
|
||||
throw new Error('Отправка в лог на сервере недоступна');
|
||||
}
|
||||
const ok = await sender(buildSendPayload(report));
|
||||
if (!ok) {
|
||||
throw new Error('Не удалось отправить ошибку в лог на сервере');
|
||||
}
|
||||
showFeedback('Ошибка отправлена в лог на сервере');
|
||||
} catch (error) {
|
||||
showFeedback(error?.message || 'Не удалось отправить ошибку в лог на сервере', 'error');
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
|
||||
dialog?.focus?.();
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
} catch (error) {
|
||||
console.warn('boot error menu failed', error);
|
||||
}
|
||||
};
|
||||
const show = (title, text, extra = {}) => {
|
||||
try {
|
||||
const reportState = {
|
||||
kind: safeString(extra.kind || title || 'boot_error', 64),
|
||||
title: safeString(title || 'BOOT ERROR', 128),
|
||||
message: safeString(extra.message || text || '', 500),
|
||||
stack: safeString(extra.stack || '', 8000),
|
||||
sourceUrl: safeString(extra.sourceUrl || extra.filename || '', 240),
|
||||
lineNumber: Number.isFinite(extra.lineNumber) ? extra.lineNumber : (Number.isFinite(extra.lineno) ? extra.lineno : null),
|
||||
columnNumber: Number.isFinite(extra.columnNumber) ? extra.columnNumber : (Number.isFinite(extra.colno) ? extra.colno : null),
|
||||
reasonType: safeString(extra.reasonType || '', 64),
|
||||
clientTs: Number.isFinite(extra.clientTs) ? extra.clientTs : Date.now(),
|
||||
};
|
||||
setState(reportState);
|
||||
|
||||
let el = document.getElementById('boot-error-overlay');
|
||||
if (!el) {
|
||||
el = document.createElement('pre');
|
||||
@@ -72,17 +348,46 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
el.style.lineHeight = '1.4';
|
||||
el.style.zIndex = '999999';
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
el.style.cursor = 'pointer';
|
||||
el.style.userSelect = 'none';
|
||||
el.style.webkitUserSelect = 'none';
|
||||
el.style.touchAction = 'manipulation';
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('aria-haspopup', 'dialog');
|
||||
document.body.appendChild(el);
|
||||
el.addEventListener('click', openMenu);
|
||||
el.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
|
||||
el.setAttribute('aria-label', `${title}. Нажмите, чтобы открыть меню действий.`);
|
||||
el.title = 'Нажмите, чтобы открыть меню действий';
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener('error', (e) => {
|
||||
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`);
|
||||
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
|
||||
kind: 'window_error',
|
||||
message: e?.message || '',
|
||||
stack: e?.error?.stack || '',
|
||||
sourceUrl: e?.filename || '',
|
||||
lineNumber: e?.lineno,
|
||||
columnNumber: e?.colno,
|
||||
reasonType: e?.error?.constructor?.name || e?.constructor?.name || 'ErrorEvent',
|
||||
});
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e?.reason;
|
||||
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'));
|
||||
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'), {
|
||||
kind: 'unhandled_rejection',
|
||||
message: reason?.message || String(reason || 'Unhandled promise rejection'),
|
||||
stack: reason?.stack || '',
|
||||
reasonType: reason?.constructor?.name || typeof reason,
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
+122
-475
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
navigate,
|
||||
getRoute,
|
||||
parseRouteFromPath,
|
||||
PRE_AUTH_PAGES,
|
||||
getSwipeNavigationTarget,
|
||||
syncTrackedRouteHistory,
|
||||
rememberToolbarRoute,
|
||||
resetRememberedToolbarRoutes,
|
||||
resolveToolbarActive,
|
||||
} from './router.js';
|
||||
import { renderToolbar } from './components/toolbar.js';
|
||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||
import {
|
||||
captureClientError,
|
||||
getLastClientErrorPayload,
|
||||
setClientErrorSentNotifier,
|
||||
setClientErrorTransport,
|
||||
} from './services/client-error-reporter.js';
|
||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
@@ -87,7 +87,7 @@ 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 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=202608191738';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
@@ -173,14 +173,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,9 +194,6 @@ 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 GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
@@ -224,6 +213,10 @@ setClientErrorSentNotifier((payload) => {
|
||||
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
|
||||
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
|
||||
});
|
||||
window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ = (payload) => authService.reportClientError(payload);
|
||||
window.__SHINE_SEND_CLIENT_ERROR__ = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
|
||||
window.__SHINE_GET_LAST_CLIENT_ERROR__ = () => getLastClientErrorPayload();
|
||||
window.__SHINE_SHOW_TOAST__ = (message, kind = 'success') => showToast(message, { kind });
|
||||
initPwaInstallPromptHandling();
|
||||
initCallUiOverlay();
|
||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||
@@ -237,6 +230,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 +302,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 +417,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 +1102,6 @@ function renderPageFailureFallback(pageId, error) {
|
||||
});
|
||||
|
||||
screenEl.innerHTML = '';
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -1433,7 +1138,6 @@ function renderPageFailureFallback(pageId, error) {
|
||||
}
|
||||
|
||||
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 +1154,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 {
|
||||
@@ -1513,19 +1174,6 @@ 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,
|
||||
};
|
||||
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 +1656,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -234,6 +234,203 @@ function buildThreadRoute(messageRef, selector) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const name = String(channelName || '').trim();
|
||||
if (!ownerBch || !name) return '';
|
||||
return `${ownerBch}/${name}`;
|
||||
}
|
||||
|
||||
function getChannelScrollRoot() {
|
||||
return document.getElementById('app-screen');
|
||||
}
|
||||
|
||||
function scrollRootBy(delta, smooth = false) {
|
||||
const root = getChannelScrollRoot();
|
||||
const behavior = smooth ? 'smooth' : 'auto';
|
||||
if (root && typeof root.scrollBy === 'function') {
|
||||
root.scrollBy({ top: delta, behavior });
|
||||
return;
|
||||
}
|
||||
window.scrollBy({ top: delta, behavior });
|
||||
}
|
||||
|
||||
function getUnreadAnchorViewportFraction(unreadCount = 0) {
|
||||
const count = Math.max(0, Number(unreadCount || 0));
|
||||
if (count <= 1) return 0.68;
|
||||
if (count <= 3) return 0.56;
|
||||
if (count <= 7) return 0.48;
|
||||
return 0.42;
|
||||
}
|
||||
|
||||
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
|
||||
if (!element) return false;
|
||||
const root = getChannelScrollRoot();
|
||||
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
|
||||
const rect = element.getBoundingClientRect();
|
||||
const delta = rect.top - targetTop;
|
||||
if (Math.abs(delta) < 2) return true;
|
||||
scrollRootBy(delta, smooth);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
|
||||
return scrollElementToViewportFraction(
|
||||
screen.querySelector('.channel-unread-line'),
|
||||
getUnreadAnchorViewportFraction(unreadCount),
|
||||
smooth,
|
||||
);
|
||||
}
|
||||
|
||||
function createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
settingKey,
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount,
|
||||
}) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
const canWrite = !!(settingKey && login && storagePwd);
|
||||
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
|
||||
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
|
||||
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
|
||||
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
||||
|
||||
let desiredSeenCount = safeInitialSeenCount;
|
||||
let persistedSeenCount = safeInitialSeenCount;
|
||||
let inFlight = false;
|
||||
let disposed = false;
|
||||
let rafId = 0;
|
||||
let timerId = 0;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
timerId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const queueFlush = (delayMs = 180) => {
|
||||
if (disposed || !canWrite) return;
|
||||
clearTimer();
|
||||
timerId = setTimeout(() => {
|
||||
timerId = 0;
|
||||
void flush();
|
||||
}, Math.max(0, Number(delayMs) || 0));
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
if (disposed || !canWrite) return;
|
||||
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
||||
if (next <= persistedSeenCount) return;
|
||||
if (inFlight) {
|
||||
queueFlush(120);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = true;
|
||||
try {
|
||||
await authService.upsertUserSetting({
|
||||
login,
|
||||
settingType: 1,
|
||||
settingKey,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: next,
|
||||
storagePwd,
|
||||
});
|
||||
persistedSeenCount = next;
|
||||
} catch {
|
||||
queueFlush(800);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const collectSeenCount = () => {
|
||||
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
||||
if (!cards.length) return safeInitialSeenCount;
|
||||
if (!unreadLine) return safeMessagesCount;
|
||||
|
||||
const root = getChannelScrollRoot();
|
||||
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
|
||||
let seen = safeInitialSeenCount;
|
||||
for (const card of cards) {
|
||||
const localNumber = Number(card.dataset.localNumber || 0);
|
||||
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
|
||||
const rect = card.getBoundingClientRect();
|
||||
if (rect.top > thresholdTop + 1) break;
|
||||
seen = Math.max(seen, localNumber);
|
||||
}
|
||||
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
|
||||
};
|
||||
|
||||
const measure = () => {
|
||||
if (disposed) return;
|
||||
if (rafId) return;
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = 0;
|
||||
const next = collectSeenCount();
|
||||
if (next > desiredSeenCount) {
|
||||
desiredSeenCount = next;
|
||||
queueFlush(180);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scrollRoot = getChannelScrollRoot();
|
||||
const onScroll = () => measure();
|
||||
const onResize = () => measure();
|
||||
|
||||
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
|
||||
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
|
||||
} else {
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
|
||||
if (initialSyncRequired) {
|
||||
void authService.upsertUserSetting({
|
||||
login,
|
||||
settingType: 1,
|
||||
settingKey,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: safeMessagesCount,
|
||||
storagePwd,
|
||||
}).catch(() => {});
|
||||
persistedSeenCount = safeMessagesCount;
|
||||
desiredSeenCount = safeMessagesCount;
|
||||
} else {
|
||||
window.setTimeout(() => measure(), 120);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
disposed = true;
|
||||
clearTimer();
|
||||
if (rafId) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
|
||||
scrollRoot.removeEventListener('scroll', onScroll);
|
||||
} else {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
measure,
|
||||
};
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
@@ -1249,6 +1446,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 +1508,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 +1560,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 +1578,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();
|
||||
@@ -1433,6 +1638,7 @@ async function loadFromApi(route, channelId) {
|
||||
return {
|
||||
channel: {
|
||||
name: payload.channel?.channelName || 'неизвестный канал',
|
||||
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
|
||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||
description: String(payload.channel?.channelDescription || '').trim(),
|
||||
@@ -1445,6 +1651,8 @@ async function loadFromApi(route, channelId) {
|
||||
posts,
|
||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||
reverseChannelMissingWarning,
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
selector,
|
||||
@@ -1733,6 +1941,9 @@ function renderPostCard(post, {
|
||||
if (refKey) {
|
||||
card.dataset.messageKey = refKey;
|
||||
}
|
||||
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
|
||||
card.dataset.localNumber = String(Number(post.localNumber));
|
||||
}
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
if (!post.messageRef || !selector) return card;
|
||||
@@ -1898,6 +2109,10 @@ function renderPostCard(post, {
|
||||
}
|
||||
|
||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
||||
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
||||
const readCount = Math.max(0, messagesCount - unreadCount);
|
||||
|
||||
if (channelData.reverseChannelMissingWarning) {
|
||||
const reverseWarning = document.createElement('p');
|
||||
reverseWarning.className = 'channel-head-meta';
|
||||
@@ -1923,6 +2138,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const postsByKey = new Map();
|
||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||
let unreadLineInserted = unreadCount === 0;
|
||||
const feedItems = [
|
||||
...metaEvents.map((event) => ({
|
||||
type: 'meta',
|
||||
@@ -1944,6 +2160,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
|
||||
if (feedItems.length) {
|
||||
feedItems.forEach((item) => {
|
||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
unreadLine.textContent = 'Не прочитанные сообщения';
|
||||
feed.append(unreadLine);
|
||||
unreadLineInserted = true;
|
||||
}
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
return;
|
||||
@@ -1995,10 +2218,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
|
||||
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
|
||||
}
|
||||
|
||||
const tracker = createChannelReadTracker({
|
||||
screen,
|
||||
routeKey,
|
||||
settingKey: buildChannelSettingsKey(
|
||||
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||
channelData.channel?.name || channelData.channel?.channelName,
|
||||
),
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
initialSeenCount: readCount,
|
||||
});
|
||||
|
||||
return tracker.cleanup;
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
|
||||
@@ -973,8 +973,8 @@ function openTopChannelsMenu({
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ divider: true },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ label: 'Создать канал', action: () => onSubscribeChannel?.() },
|
||||
{ divider: true },
|
||||
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||
];
|
||||
|
||||
@@ -1219,7 +1219,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channel-row-controls';
|
||||
|
||||
@@ -1230,38 +1229,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const count = document.createElement('span');
|
||||
count.className = 'unread channel-row-count';
|
||||
const unreadCount = Number(channel.unreadCount || 0);
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
if (!isGuest) {
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.type = 'button';
|
||||
menuButton.className = 'channel-menu-trigger';
|
||||
menuButton.textContent = '…';
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(menuButton);
|
||||
listState.revealedCounters.add(channel.id);
|
||||
|
||||
if (listState.openMenuId === channel.id) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
return;
|
||||
}
|
||||
|
||||
listState.openMenuId = channel.id;
|
||||
openChannelMenu({
|
||||
listState,
|
||||
channel,
|
||||
anchorEl: menuButton,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
||||
rerenderList,
|
||||
});
|
||||
rerenderList();
|
||||
});
|
||||
controls.append(menuButton);
|
||||
if (unreadCount > 0) {
|
||||
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
controls.append(count);
|
||||
}
|
||||
controls.append(time, count);
|
||||
controls.append(time);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => {
|
||||
@@ -1278,14 +1250,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
@@ -1431,9 +1395,6 @@ export function render({ navigate, route, chrome }) {
|
||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
const rerenderList = () => {
|
||||
@@ -1454,19 +1415,15 @@ export function render({ navigate, route, chrome }) {
|
||||
createInMyBtn.style.display = '';
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
screen.append(contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
rerenderList();
|
||||
|
||||
@@ -572,12 +572,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 +796,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 +811,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 +871,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,11 +900,7 @@ 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();
|
||||
};
|
||||
|
||||
@@ -1010,29 +1058,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 +1260,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 +1314,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 +1357,7 @@ export function render({ navigate, route, chrome }) {
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1393,7 +1419,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 +1471,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 +1519,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 +1554,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,28 @@
|
||||
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 { 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 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;
|
||||
@@ -65,10 +72,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,6 +151,15 @@ function formatChatRowTime(ts) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
@@ -98,23 +176,116 @@ export function render({ navigate, chrome }) {
|
||||
<span class="dm-head-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="dm-head-title">Контакты</h1>
|
||||
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
||||
<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">
|
||||
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</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>
|
||||
`;
|
||||
const headName = head.querySelector('.dm-head-name');
|
||||
if (headName) headName.textContent = login;
|
||||
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||
// land on the content layer underneath. Render the open menu as a body portal.
|
||||
menuTemplate?.remove();
|
||||
|
||||
let menuPortal = null;
|
||||
|
||||
const closeHeadMenu = () => {
|
||||
menuPortal?.remove();
|
||||
menuPortal = null;
|
||||
menuButton?.setAttribute('aria-expanded', 'false');
|
||||
menuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionHeadMenu = () => {
|
||||
if (!menuPortal || !menuButton) return;
|
||||
const rect = menuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
menuPortal.style.top = `${Math.round(rect.bottom + 7)}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">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeHeadMenu();
|
||||
navigate('contact-search-view');
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
menuPortal = portal;
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
menuWrap?.classList.add('is-open');
|
||||
positionHeadMenu();
|
||||
};
|
||||
|
||||
menuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (menuPortal) closeHeadMenu();
|
||||
else openHeadMenu();
|
||||
});
|
||||
|
||||
const onOutsideClick = (event) => {
|
||||
if (!menuPortal) return;
|
||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const 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 +294,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 +309,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 +331,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 +428,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,5 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, clearAuthMessages, state } from '../state.js';
|
||||
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
checkLoginExistsOnSolana,
|
||||
@@ -426,7 +426,13 @@ export function render({ navigate }) {
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Зарегистрироваться',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
},
|
||||
},
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
resetRegistrationFlow,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
@@ -102,7 +103,10 @@ export function render({ navigate }) {
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
cancelButton.addEventListener('click', () => {
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
});
|
||||
|
||||
const okButton = document.createElement('button');
|
||||
okButton.className = 'primary-btn';
|
||||
@@ -190,7 +194,13 @@ export function render({ navigate }) {
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Сохранение ключей',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
},
|
||||
},
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
resetRegistrationFlow,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
||||
replacement.addEventListener('click', () => {
|
||||
stageClosed = true;
|
||||
stopTimers();
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
});
|
||||
headerBackButton.replaceWith(replacement);
|
||||
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
|
||||
replacement.addEventListener('click', () => {
|
||||
loginCompleted = true;
|
||||
stopAutoLogin();
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
});
|
||||
headerBackButton.replaceWith(replacement);
|
||||
|
||||
@@ -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');
|
||||
@@ -505,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractContactsFromDialogs(dialogs) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
|
||||
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
|
||||
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
|
||||
const login = String(dialog?.peerLogin || '').trim();
|
||||
if (!login) return;
|
||||
const key = login.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(login);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
||||
const keyBytes = utf8Bytes(String(key || ''));
|
||||
const valueBytes = utf8Bytes(String(value || ''));
|
||||
@@ -1022,6 +1042,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 +1159,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 +1247,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 +1323,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 +2514,10 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
normalizeDmLogin(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async buildSignedDmBlock({
|
||||
signerLogin,
|
||||
fromLogin,
|
||||
@@ -2488,9 +2530,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 +2632,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 +2716,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 +2748,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 +2772,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 +2788,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);
|
||||
@@ -2817,7 +2868,11 @@ export class AuthService {
|
||||
async listContacts() {
|
||||
const response = await this.ws.request('ListContacts', {});
|
||||
if (response.status !== 200) throw opError('ListContacts', response);
|
||||
return response.payload || {};
|
||||
const payload = response.payload || {};
|
||||
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
|
||||
payload.contacts = extractContactsFromDialogs(payload.dialogs);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -2867,6 +2922,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);
|
||||
|
||||
@@ -6,6 +6,7 @@ let transport = null;
|
||||
let transportDepth = 0;
|
||||
const recentFingerprints = new Map();
|
||||
let notifySent = null;
|
||||
let lastCapturedPayload = null;
|
||||
|
||||
function nowTs() {
|
||||
return Date.now();
|
||||
@@ -85,6 +86,11 @@ export function setClientErrorSentNotifier(fn) {
|
||||
notifySent = typeof fn === 'function' ? fn : null;
|
||||
}
|
||||
|
||||
export function getLastClientErrorPayload() {
|
||||
if (!lastCapturedPayload) return null;
|
||||
return { ...lastCapturedPayload };
|
||||
}
|
||||
|
||||
export function isClientErrorReportingEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
|
||||
@@ -104,6 +110,7 @@ export function setClientErrorReportingEnabled(enabled) {
|
||||
export async function captureClientError(details = {}) {
|
||||
const payload = buildPayload(details);
|
||||
if (!payload.message) return false;
|
||||
lastCapturedPayload = payload;
|
||||
|
||||
const fingerprint = details.dedupeKey || makeFingerprint(payload);
|
||||
if (isDuplicate(fingerprint)) return false;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -925,6 +925,14 @@ export async function refreshSessions() {
|
||||
return state.sessions;
|
||||
}
|
||||
|
||||
export function resetRegistrationFlow() {
|
||||
const next = createInitialState();
|
||||
state.registrationDraft = next.registrationDraft;
|
||||
state.registrationHelp = next.registrationHelp;
|
||||
state.registrationPayment = next.registrationPayment;
|
||||
state.keyStorage = next.keyStorage;
|
||||
}
|
||||
|
||||
function resetStateForSignedOut() {
|
||||
const next = createInitialState({ withStoredSession: false });
|
||||
state.chats = next.chats;
|
||||
|
||||
+208
-22
@@ -3324,6 +3324,10 @@ textarea.input {
|
||||
z-index: 24;
|
||||
}
|
||||
|
||||
.boot-error-menu-shell {
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -3341,6 +3345,16 @@ textarea.input {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.boot-error-menu-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boot-error-menu-message {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.network-board {
|
||||
position: relative;
|
||||
height: 290px;
|
||||
@@ -4248,6 +4262,36 @@ textarea.input {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-unread-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(244, 202, 102, 0.46);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9));
|
||||
color: #ffe6a7;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 0 1px rgba(255, 226, 155, 0.08), 0 10px 24px rgba(6, 10, 20, 0.35);
|
||||
}
|
||||
|
||||
.channel-unread-line::before,
|
||||
.channel-unread-line::after {
|
||||
content: '';
|
||||
flex: 1 1 0;
|
||||
height: 1px;
|
||||
min-width: 18px;
|
||||
background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent);
|
||||
}
|
||||
|
||||
.channels-screen--channel .channel-feed {
|
||||
gap: 2px;
|
||||
margin-left: -7px;
|
||||
@@ -6268,6 +6312,37 @@ html, body { overflow-x: hidden; }
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dm-chat-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dm-chat-screen > .dm-chat-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dm-chat-composer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 12;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen) > .dm-chat-screen {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-screen) {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
@@ -6282,27 +6357,14 @@ html, body { overflow-x: hidden; }
|
||||
.screen-content:has(> .dm-chat-screen) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb {
|
||||
background: rgba(212, 175, 55, 0.7);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(240, 198, 76, 0.9);
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dm-messages-log {
|
||||
@@ -6401,11 +6463,10 @@ html, body { overflow-x: hidden; }
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-inline: -14px;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
margin-inline: 0;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid rgba(212, 175, 55, 0.22);
|
||||
background: rgba(8, 12, 20, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
@@ -7900,3 +7961,128 @@ html, body { overflow-x: hidden; }
|
||||
color: #D4AF37;
|
||||
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
|
||||
}
|
||||
|
||||
/* Contacts header overflow menu — vertical ellipsis + glass dropdown. */
|
||||
.dm-head-menu-wrap {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 40;
|
||||
}
|
||||
.dm-head-menu-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #FFD98A;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.dm-head-menu-btn:active { transform: scale(0.94); }
|
||||
.dm-head-menu-wrap.is-open .dm-head-menu-btn,
|
||||
.dm-head-menu-btn:focus-visible {
|
||||
outline: none;
|
||||
background: rgba(240, 184, 46, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(240, 184, 46, 0.24), 0 0 18px rgba(240, 184, 46, 0.12);
|
||||
}
|
||||
.dm-head-menu-dots {
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3.5px;
|
||||
}
|
||||
.dm-head-menu-dots i {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 7px rgba(240, 184, 46, 0.45);
|
||||
}
|
||||
.dm-head-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
width: max-content;
|
||||
min-width: 190px;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(240, 184, 46, 0.26);
|
||||
border-radius: 15px;
|
||||
background: linear-gradient(155deg, rgba(22, 24, 31, 0.97), rgba(10, 12, 18, 0.97));
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42), 0 0 20px rgba(240, 184, 46, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
transform-origin: top right;
|
||||
animation: dm-head-menu-in 140ms ease-out both;
|
||||
}
|
||||
.dm-head-menu[hidden] { display: none; }
|
||||
.dm-head-menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: 18px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
transform: rotate(45deg);
|
||||
border-left: 1px solid rgba(240, 184, 46, 0.22);
|
||||
border-top: 1px solid rgba(240, 184, 46, 0.22);
|
||||
background: rgba(19, 21, 28, 0.98);
|
||||
}
|
||||
.dm-head-menu-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #FCEAC0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-head-menu-item svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
flex: 0 0 auto;
|
||||
color: #E7B83D;
|
||||
filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.22));
|
||||
}
|
||||
.dm-head-menu-item:hover,
|
||||
.dm-head-menu-item:focus-visible,
|
||||
.dm-head-menu-item:active {
|
||||
outline: none;
|
||||
background: rgba(240, 184, 46, 0.09);
|
||||
}
|
||||
@keyframes dm-head-menu-in {
|
||||
from { opacity: 0; transform: translateY(-5px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-head-menu { animation: none; }
|
||||
.dm-head-menu-btn { transition: none; }
|
||||
}
|
||||
|
||||
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
|
||||
.dm-head-menu--portal {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
z-index: 10000;
|
||||
pointer-events: auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
+53
-140
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SHiNE Solana Arweave Viewer
|
||||
|
||||
Основной рабочий файл:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Подробное описание работы, параметров и готовых ссылок:
|
||||
|
||||
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||
|
||||
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||
@@ -0,0 +1,11 @@
|
||||
# SHiNE Solana Arweave Viewer
|
||||
|
||||
Основной рабочий файл:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Подробное описание работы, параметров и готовых ссылок:
|
||||
|
||||
- `КАК_ЭТО_РАБОТАЕТ.md`
|
||||
|
||||
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
# SHiNE Solana Arweave Viewer: как это работает
|
||||
|
||||
## Что лежит в папке
|
||||
|
||||
Основной и единственный рабочий файл viewer:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Это автономный single-file viewer:
|
||||
|
||||
- весь HTML внутри одного файла;
|
||||
- все стили внутри этого же файла;
|
||||
- весь JavaScript внутри этого же файла;
|
||||
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
|
||||
|
||||
Для запуска viewer достаточно открыть один `index.html`.
|
||||
|
||||
## Что делает viewer
|
||||
|
||||
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
|
||||
|
||||
Он умеет:
|
||||
|
||||
- принимать логин пользователя SHiNE;
|
||||
- принимать `Solana RPC` через форму или через URL-параметр;
|
||||
- принимать `Arweave gateway` через форму или через URL-параметр;
|
||||
- сохранять эти значения в `localStorage`;
|
||||
- вычислять `user_pda` по логину для программы `shine_users`;
|
||||
- читать `PDA` напрямую из `Solana`;
|
||||
- распарсивать содержимое записи `shine_users`;
|
||||
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
|
||||
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
|
||||
|
||||
## Какие URL-параметры поддерживаются
|
||||
|
||||
Viewer понимает такие параметры:
|
||||
|
||||
- `login`
|
||||
- `solanaRpc`
|
||||
- `arweaveGateway`
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
|
||||
```
|
||||
|
||||
## Предустановленные адреса
|
||||
|
||||
### Solana RPC
|
||||
|
||||
- `https://solana-rpc.publicnode.com`
|
||||
- `https://public.rpc.solanavibestation.com/`
|
||||
|
||||
### Arweave gateway
|
||||
|
||||
- `https://arweave.net`
|
||||
|
||||
## Почему одна из RPC может не работать
|
||||
|
||||
Адрес:
|
||||
|
||||
- `https://public.rpc.solanavibestation.com/`
|
||||
|
||||
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
|
||||
|
||||
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
|
||||
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
|
||||
|
||||
Практически для viewer лучше использовать:
|
||||
|
||||
- `https://solana-rpc.publicnode.com`
|
||||
|
||||
## Ссылки на загруженный файл в Arweave
|
||||
|
||||
Основная ссылка:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
|
||||
|
||||
Та же ссылка с тестовым логином `aidartest`:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
|
||||
|
||||
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
|
||||
|
||||
## Что будет, если поставить devnet RPC
|
||||
|
||||
Официальный публичный `Solana devnet RPC`:
|
||||
|
||||
- `https://api.devnet.solana.com`
|
||||
|
||||
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
|
||||
|
||||
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
|
||||
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
|
||||
|
||||
- программа `shine_users` должна быть задеплоена в `devnet`;
|
||||
- в `devnet` должен существовать нужный `PDA` для логина;
|
||||
- формат записи должен совпадать;
|
||||
- данные пользователя должны реально лежать в этой тестовой сети.
|
||||
|
||||
Если этого нет, viewer просто не найдёт запись.
|
||||
|
||||
## Источник по devnet RPC
|
||||
|
||||
Официальная документация Solana:
|
||||
|
||||
- `https://solana.com/docs/references/clusters`
|
||||
|
||||
Там указан devnet endpoint:
|
||||
|
||||
- `https://api.devnet.solana.com`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
# SHiNE Solana Arweave Viewer: как это работает
|
||||
|
||||
## Что лежит в папке
|
||||
|
||||
Основной и единственный рабочий файл viewer:
|
||||
|
||||
- `index.html`
|
||||
|
||||
Это автономный single-file viewer:
|
||||
|
||||
- весь HTML внутри одного файла;
|
||||
- все стили внутри этого же файла;
|
||||
- весь JavaScript внутри этого же файла;
|
||||
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
|
||||
|
||||
Для запуска viewer достаточно открыть один `index.html`.
|
||||
|
||||
## Что делает viewer
|
||||
|
||||
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
|
||||
|
||||
Он умеет:
|
||||
|
||||
- принимать логин пользователя SHiNE;
|
||||
- принимать `Solana RPC` через форму или через URL-параметр;
|
||||
- принимать `Arweave gateway` через форму или через URL-параметр;
|
||||
- сохранять эти значения в `localStorage`;
|
||||
- вычислять `user_pda` по логину для программы `shine_users`;
|
||||
- читать `PDA` напрямую из `Solana`;
|
||||
- распарсивать содержимое записи `shine_users`;
|
||||
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
|
||||
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
|
||||
|
||||
## Какие URL-параметры поддерживаются
|
||||
|
||||
Viewer понимает такие параметры:
|
||||
|
||||
- `login`
|
||||
- `solanaRpc`
|
||||
- `arweaveGateway`
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
|
||||
```
|
||||
|
||||
## Предустановленные адреса
|
||||
|
||||
### Solana RPC
|
||||
|
||||
- `https://solana-rpc.publicnode.com`
|
||||
- `https://public.rpc.solanavibestation.com/`
|
||||
|
||||
### Arweave gateway
|
||||
|
||||
- `https://arweave.net`
|
||||
|
||||
## Почему одна из RPC может не работать
|
||||
|
||||
Адрес:
|
||||
|
||||
- `https://public.rpc.solanavibestation.com/`
|
||||
|
||||
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
|
||||
|
||||
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
|
||||
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
|
||||
|
||||
Практически для viewer лучше использовать:
|
||||
|
||||
- `https://solana-rpc.publicnode.com`
|
||||
|
||||
## Ссылки на загруженный файл в Arweave
|
||||
|
||||
Основная ссылка:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
|
||||
|
||||
Та же ссылка с тестовым логином `aidartest`:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
|
||||
|
||||
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
|
||||
|
||||
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
|
||||
|
||||
## Что будет, если поставить devnet RPC
|
||||
|
||||
Официальный публичный `Solana devnet RPC`:
|
||||
|
||||
- `https://api.devnet.solana.com`
|
||||
|
||||
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
|
||||
|
||||
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
|
||||
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
|
||||
|
||||
- программа `shine_users` должна быть задеплоена в `devnet`;
|
||||
- в `devnet` должен существовать нужный `PDA` для логина;
|
||||
- формат записи должен совпадать;
|
||||
- данные пользователя должны реально лежать в этой тестовой сети.
|
||||
|
||||
Если этого нет, viewer просто не найдёт запись.
|
||||
|
||||
## Источник по devnet RPC
|
||||
|
||||
Официальная документация Solana:
|
||||
|
||||
- `https://solana.com/docs/references/clusters`
|
||||
|
||||
Там указан devnet endpoint:
|
||||
|
||||
- `https://api.devnet.solana.com`
|
||||
Reference in New Issue
Block a user