SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
db37f35d09 | ||
|
|
d8f5fe4fd0 | ||
|
|
015fade4c0 | ||
|
|
da14b51e80 | ||
|
|
7a5ac01d1c | ||
|
|
5e6d64e965 | ||
|
|
c425fa41aa | ||
|
|
781157299f | ||
|
|
0f30317bb4 | ||
|
|
1f1bc0a7b9 | ||
|
|
60c8a6608a | ||
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
c70f18fcf4 | ||
|
|
d3e6aa2be2 | ||
|
|
b7a869c514 | ||
|
|
19362b950a | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 | ||
|
|
23bdadab56 | ||
|
|
47a4844ce8 | ||
|
|
8741be6cba | ||
|
|
4656a03ea9 | ||
|
|
5763eb828e | ||
|
|
6a5c20a165 | ||
|
|
fac166f186 | ||
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 |
@@ -98,14 +98,9 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@ import java.util.Objects;
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|
||||
+4
-1
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
||||
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
@@ -31,4 +34,4 @@ public final class ShineSignatureConstants {
|
||||
|
||||
/** Длина публичного ключа Ed25519. */
|
||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,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() {}
|
||||
|
||||
@@ -43,10 +57,8 @@ public final class DatabaseInitializer {
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
@@ -100,6 +112,35 @@ 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);
|
||||
currentVersion = SCHEMA_VERSION_11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
|
||||
private static volatile DbController instance;
|
||||
|
||||
private final PostgresDbController delegate;
|
||||
private volatile boolean dmDialogStateBootstrapped;
|
||||
|
||||
private DbController() {
|
||||
this.delegate = PostgresDbController.getInstance();
|
||||
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new DbController();
|
||||
DbController created = new DbController();
|
||||
instance = created;
|
||||
created.bootstrapDmDialogStateIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
private void bootstrapDmDialogStateIfNeeded() {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
synchronized (this) {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
try (Connection connection = delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
dmDialogStateBootstrapped = true;
|
||||
} catch (SQLException e) {
|
||||
instance = null;
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,23 +58,17 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+6
-6
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
SET likes_count = GREATEST(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
likes_count - COALESCE((
|
||||
SELECT COUNT(*)::int
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
SET replies_count = GREATEST(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)::int
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
|
||||
@@ -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;
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserNotificationsStateDAO {
|
||||
private static volatile UserNotificationsStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserNotificationsStateDAO() {}
|
||||
|
||||
public static UserNotificationsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserNotificationsStateDAO.class) {
|
||||
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_notifications_state (
|
||||
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||
created_at_ms = EXCLUDED.created_at_ms,
|
||||
source_login = EXCLUDED.source_login,
|
||||
target_login = EXCLUDED.target_login,
|
||||
target_bch_name = EXCLUDED.target_bch_name,
|
||||
target_block_number = EXCLUDED.target_block_number,
|
||||
target_block_hash = EXCLUDED.target_block_hash,
|
||||
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||
source_text = EXCLUDED.source_text
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, e.getOwnerLogin());
|
||||
ps.setString(i++, e.getNotificationKind());
|
||||
ps.setLong(i++, e.getCreatedAtMs());
|
||||
ps.setString(i++, e.getSourceLogin());
|
||||
ps.setString(i++, e.getSourceBchName());
|
||||
ps.setInt(i++, e.getSourceBlockNumber());
|
||||
ps.setBytes(i++, e.getSourceBlockHash());
|
||||
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||
ps.setInt(i++, e.getSourceMsgSubType());
|
||||
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, kind);
|
||||
ps.setInt(3, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setNotificationKind(rs.getString("notification_kind"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceLogin(rs.getString("source_login"));
|
||||
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||
e.setSourceText(rs.getString("source_text"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserSettingsDAO — хранение пользовательских настроек.
|
||||
*
|
||||
* Правило:
|
||||
* - уникальность: login + setting_type + setting_key
|
||||
* - запись обновляется только если time_ms новее
|
||||
* - synced=true означает, что значение уже дошло до второго сервера
|
||||
*/
|
||||
public final class UserSettingsDAO {
|
||||
|
||||
private static volatile UserSettingsDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsDAO() {}
|
||||
|
||||
public static UserSettingsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_settings (
|
||||
login, setting_type, setting_key, time_ms,
|
||||
value_text, value_num, client_key, signature, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (login, setting_type, setting_key)
|
||||
DO UPDATE SET
|
||||
time_ms = EXCLUDED.time_ms,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_num = EXCLUDED.value_num,
|
||||
client_key = EXCLUDED.client_key,
|
||||
signature = EXCLUDED.signature,
|
||||
synced = EXCLUDED.synced
|
||||
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setInt(2, e.getSettingType());
|
||||
ps.setString(3, e.getSettingKey());
|
||||
ps.setLong(4, e.getTimeMs());
|
||||
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
||||
ps.setLong(6, e.getValueNum());
|
||||
|
||||
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
||||
else ps.setString(7, e.getClientKey());
|
||||
|
||||
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
||||
else ps.setString(8, e.getSignature());
|
||||
|
||||
ps.setBoolean(9, e.isSynced());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return upsertIfNewer(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLoginTypeKey(c, login, settingType, settingKey);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND (
|
||||
time_ms > ?
|
||||
OR (time_ms = ? AND setting_key > ?)
|
||||
)
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setLong(2, Math.max(0L, afterTimeMs));
|
||||
ps.setLong(3, Math.max(0L, afterTimeMs));
|
||||
ps.setString(4, afterSettingKey == null ? "" : afterSettingKey);
|
||||
ps.setInt(5, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserSettingEntry> listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND synced = FALSE
|
||||
ORDER BY time_ms ASC, setting_key ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserSettingEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = TRUE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, settingType);
|
||||
ps.setString(3, settingKey);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE user_settings
|
||||
SET synced = FALSE
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced() throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return markAllUnsynced(c);
|
||||
}
|
||||
}
|
||||
|
||||
public int markAllUnsynced(Connection c) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) {
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingEntry e = new UserSettingEntry();
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSettingType(rs.getInt("setting_type"));
|
||||
e.setSettingKey(rs.getString("setting_key"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setValueText(rs.getString("value_text"));
|
||||
e.setValueNum(rs.getLong("value_num"));
|
||||
|
||||
String clientKey = rs.getString("client_key");
|
||||
if (rs.wasNull()) clientKey = null;
|
||||
e.setClientKey(clientKey);
|
||||
|
||||
String signature = rs.getString("signature");
|
||||
if (rs.wasNull()) signature = null;
|
||||
e.setSignature(signature);
|
||||
|
||||
e.setSynced(rs.getBoolean("synced"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserSettingsSyncPeerStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class UserSettingsSyncPeerStateDAO {
|
||||
|
||||
private static volatile UserSettingsSyncPeerStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserSettingsSyncPeerStateDAO() {}
|
||||
|
||||
public static UserSettingsSyncPeerStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserSettingsSyncPeerStateDAO.class) {
|
||||
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
|
||||
if (existing != null) return existing;
|
||||
long nowMs = System.currentTimeMillis();
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return get(c, ownerLogin, remoteServerLogin);
|
||||
}
|
||||
|
||||
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
|
||||
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
|
||||
FROM user_settings_sync_peer_state
|
||||
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
cursor_time_ms = EXCLUDED.cursor_time_ms,
|
||||
cursor_setting_key = EXCLUDED.cursor_setting_key,
|
||||
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||
last_error = NULL,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setLong(4, Math.max(0L, cursorTimeMs));
|
||||
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
|
||||
ps.setBoolean(6, bootstrapCompleted);
|
||||
ps.setLong(7, nowMs);
|
||||
ps.setLong(8, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_settings_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl);
|
||||
ps.setString(4, error);
|
||||
ps.setLong(5, nowMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
|
||||
}
|
||||
|
||||
public int deleteAllForOwner(String ownerLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
|
||||
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
|
||||
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
|
||||
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DirectMessageEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedDirectMessageHistoryEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int targetMode;
|
||||
private String targetSessionId;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private byte[] rawPacket;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getTargetMode() { return targetMode; }
|
||||
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
|
||||
public String getTargetSessionId() { return targetSessionId; }
|
||||
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public byte[] getRawPacket() { return rawPacket; }
|
||||
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserNotificationEntry {
|
||||
private String ownerLogin;
|
||||
private String notificationKind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBchName;
|
||||
private int sourceBlockNumber;
|
||||
private byte[] sourceBlockHash;
|
||||
private String targetLogin;
|
||||
private String targetBchName;
|
||||
private Integer targetBlockNumber;
|
||||
private byte[] targetBlockHash;
|
||||
private int sourceMsgSubType;
|
||||
private String sourceText;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getNotificationKind() { return notificationKind; }
|
||||
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBchName() { return sourceBchName; }
|
||||
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBchName() { return targetBchName; }
|
||||
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* UserSettingEntry — одна пользовательская настройка.
|
||||
*
|
||||
* Таблица: user_settings
|
||||
* - login TEXT NOT NULL
|
||||
* - setting_type INTEGER NOT NULL
|
||||
* - setting_key TEXT NOT NULL
|
||||
* - time_ms BIGINT NOT NULL
|
||||
* - value_text TEXT NOT NULL
|
||||
* - value_num BIGINT NOT NULL
|
||||
* - client_key TEXT NOT NULL
|
||||
* - signature TEXT NOT NULL
|
||||
* - synced BOOLEAN NOT NULL
|
||||
*/
|
||||
public class UserSettingEntry {
|
||||
private String login;
|
||||
private int settingType;
|
||||
private String settingKey;
|
||||
private long timeMs;
|
||||
private String valueText;
|
||||
private long valueNum;
|
||||
private String clientKey;
|
||||
private String signature;
|
||||
private boolean synced;
|
||||
|
||||
public UserSettingEntry() {}
|
||||
|
||||
public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) {
|
||||
this.login = login;
|
||||
this.settingType = settingType;
|
||||
this.settingKey = settingKey;
|
||||
this.timeMs = timeMs;
|
||||
this.valueText = valueText;
|
||||
this.valueNum = valueNum;
|
||||
this.clientKey = clientKey;
|
||||
this.signature = signature;
|
||||
this.synced = synced;
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public int getSettingType() { return settingType; }
|
||||
public void setSettingType(int settingType) { this.settingType = settingType; }
|
||||
|
||||
public String getSettingKey() { return settingKey; }
|
||||
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
||||
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
|
||||
public String getValueText() { return valueText; }
|
||||
public void setValueText(String valueText) { this.valueText = valueText; }
|
||||
|
||||
public long getValueNum() { return valueNum; }
|
||||
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
||||
|
||||
public String getClientKey() { return clientKey; }
|
||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
|
||||
public boolean isSynced() { return synced; }
|
||||
public void setSynced(boolean synced) { this.synced = synced; }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserSettingsSyncPeerStateEntry {
|
||||
private String ownerLogin;
|
||||
private String remoteServerLogin;
|
||||
private String remoteServerUrl;
|
||||
private long cursorTimeMs;
|
||||
private String cursorSettingKey;
|
||||
private boolean bootstrapCompleted;
|
||||
private Long lastSyncAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
|
||||
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||
|
||||
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||
|
||||
public long getCursorTimeMs() { return cursorTimeMs; }
|
||||
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
|
||||
|
||||
public String getCursorSettingKey() { return cursorSettingKey; }
|
||||
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
|
||||
|
||||
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||
|
||||
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
|
||||
(
|
||||
SELECT
|
||||
current_users.login AS login,
|
||||
current_users.normalized_login AS normalized_login,
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||
source_block_hash BYTEA NOT NULL,
|
||||
target_login TEXT,
|
||||
target_bch_name TEXT,
|
||||
target_block_number INTEGER,
|
||||
target_block_hash BYTEA,
|
||||
source_msg_sub_type INTEGER NOT NULL,
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,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,
|
||||
|
||||
+28
-2
@@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
|
||||
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
@@ -82,19 +88,22 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_MarkAllUserSettingsUnsynced_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UserSettingsSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
@@ -103,11 +112,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
@@ -182,6 +192,11 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
|
||||
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
|
||||
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
|
||||
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
@@ -194,6 +209,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -204,6 +220,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||
Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()),
|
||||
Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()),
|
||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
@@ -264,6 +282,11 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
|
||||
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
|
||||
|
||||
// --- userSettings ---
|
||||
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
|
||||
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
|
||||
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
@@ -276,6 +299,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
@@ -286,6 +310,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
|
||||
Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class),
|
||||
Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class),
|
||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
|
||||
+69
-2
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
import org.slf4j.Logger;
|
||||
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(
|
||||
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return b;
|
||||
}
|
||||
|
||||
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
|
||||
if (block == null || storedEntry == null) return null;
|
||||
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
long createdAtMs = block.timestamp * 1000L;
|
||||
String ownerLogin = storedEntry.getToLogin();
|
||||
String sourceLogin = storedEntry.getLogin();
|
||||
|
||||
// A user must never receive a notification for replying to their own message/thread.
|
||||
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
|
||||
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
&& block.body instanceof TextReplyBody replyBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("reply");
|
||||
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
|
||||
String ownerLogin,
|
||||
long createdAtMs,
|
||||
int msgSubType) {
|
||||
UserNotificationEntry entry = new UserNotificationEntry();
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setCreatedAtMs(createdAtMs);
|
||||
entry.setSourceLogin(storedEntry.getLogin());
|
||||
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
|
||||
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
|
||||
entry.setSourceBlockHash(storedEntry.getBlockHash());
|
||||
entry.setTargetLogin(storedEntry.getToLogin());
|
||||
entry.setTargetBchName(storedEntry.getToBchName());
|
||||
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
|
||||
entry.setTargetBlockHash(storedEntry.getToBlockHash());
|
||||
entry.setSourceMsgSubType(msgSubType);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String blockchainNameFromEntry(BlockEntry entry) {
|
||||
return entry == null ? null : entry.getBchName();
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
+14
-2
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
ChannelNameStateDAO channelNameStateDAO,
|
||||
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
|
||||
+34
@@ -144,6 +144,40 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
||||
String name = channelName == null ? "" : channelName.trim();
|
||||
return bch + "/" + name;
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(0);
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
public static class NotificationItem {
|
||||
private String kind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBlockchainName;
|
||||
private int sourceBlockNumber;
|
||||
private String sourceBlockHash;
|
||||
private Integer sourceMsgSubType;
|
||||
private Integer connectionTypeCode;
|
||||
private String sourceText;
|
||||
private String targetLogin;
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public String getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public Integer getConnectionTypeCode() { return connectionTypeCode; }
|
||||
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserSetting_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserSetting_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserSetting_Request req = (Net_GetUserSetting_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getSetting_type() == null
|
||||
|| req.getSetting_key() == null || req.getSetting_key().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login/setting_type/setting_key");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String login = CurrentUsersDAO.getInstance().getByLogin(c, req.getLogin().trim()) != null
|
||||
? req.getLogin().trim()
|
||||
: null;
|
||||
if (login == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
UserSettingEntry entry = UserSettingsDAO.getInstance().getByLoginTypeKey(c, login, req.getSetting_type(), req.getSetting_key().trim());
|
||||
if (entry == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "SETTING_NOT_FOUND", "Настройка не найдена");
|
||||
}
|
||||
|
||||
Net_GetUserSetting_Response resp = new Net_GetUserSetting_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(entry.getLogin());
|
||||
resp.setSetting_type(entry.getSettingType());
|
||||
resp.setSetting_key(entry.getSettingKey());
|
||||
resp.setTime_ms(entry.getTimeMs());
|
||||
resp.setValue_text(entry.getValueText());
|
||||
resp.setValue_num(entry.getValueNum());
|
||||
resp.setClient_key(entry.getClientKey());
|
||||
resp.setSignature(entry.getSignature());
|
||||
resp.setSynced(entry.isSynced());
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserSetting failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.userSettings;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.dao.UserSettingsDAO;
|
||||
import shine.db.entities.CurrentUserEntry;
|
||||
import shine.db.entities.UserSettingEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListUserSettings_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_ListUserSettings_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_ListUserSettings_Request req = (Net_ListUserSettings_Request) baseRequest;
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String login = req.getLogin().trim();
|
||||
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login);
|
||||
if (user == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
|
||||
List<UserSettingEntry> entries = UserSettingsDAO.getInstance().getByLogin(c, login);
|
||||
List<Net_ListUserSettings_Response.Item> items = new ArrayList<>();
|
||||
for (UserSettingEntry e : entries) {
|
||||
Net_ListUserSettings_Response.Item item = new Net_ListUserSettings_Response.Item();
|
||||
item.setLogin(e.getLogin());
|
||||
item.setSetting_type(e.getSettingType());
|
||||
item.setSetting_key(e.getSettingKey());
|
||||
item.setTime_ms(e.getTimeMs());
|
||||
item.setValue_text(e.getValueText());
|
||||
item.setValue_num(e.getValueNum());
|
||||
item.setClient_key(e.getClientKey());
|
||||
item.setSignature(e.getSignature());
|
||||
item.setSynced(e.isSynced());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
Net_ListUserSettings_Response resp = new Net_ListUserSettings_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(user.getLogin());
|
||||
resp.setSettings(items);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("ListUserSettings failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: FRIEND", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: FRIEND", t);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# TODO: уведомления и full-resync после фиксации блоков в Arweave
|
||||
|
||||
## Контекст
|
||||
|
||||
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
|
||||
|
||||
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
|
||||
|
||||
## Что проверить после внедрения Arweave
|
||||
|
||||
Когда схема Arweave и правила восстановления цепочки стабилизируются:
|
||||
|
||||
- определить окончательный source of truth для accepted/finalized blocks;
|
||||
- проверить поведение `user_notifications_state` при rollback/full-resync;
|
||||
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
|
||||
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
|
||||
- добавить интеграционный тест на divergence + full-resync + notification projection.
|
||||
|
||||
## Важно
|
||||
|
||||
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.7.0
|
||||
server.version=1.5.0
|
||||
client.version=1.8.0
|
||||
server.version=1.7.0
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Build a source bundle ZIP while excluding credentials, private keys,
|
||||
# local state, generated artifacts and other likely secrets.
|
||||
#
|
||||
# Usage:
|
||||
# ./bundle.sh
|
||||
# ./bundle.sh path/to/output.zip
|
||||
#
|
||||
# Run from anywhere inside the project; the script resolves its own directory.
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
||||
case "$OUT" in
|
||||
/*) ;;
|
||||
*) OUT="$ROOT/$OUT" ;;
|
||||
esac
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
echo "ERROR: 'zip' is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
LIST="$TMP/files.txt"
|
||||
SAFE_LIST="$TMP/safe-files.txt"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Paths / filenames that must never be bundled.
|
||||
is_denied_path() {
|
||||
local p="/$1"
|
||||
|
||||
case "$p" in
|
||||
*/.git/*|*/.git|\
|
||||
*/.gradle/*|*/.gradle|\
|
||||
*/.gradle-home/*|*/.gradle-home|\
|
||||
*/.idea/*|*/.idea|\
|
||||
*/.vscode/*|*/.vscode|\
|
||||
*/node_modules/*|*/node_modules|\
|
||||
*/target/*|*/target|\
|
||||
*/build/*|*/build|\
|
||||
*/out/*|*/out|\
|
||||
*/bin/*|*/bin|\
|
||||
*/logs/*|*/logs|\
|
||||
*/data/*|*/data|\
|
||||
*/test-ledger/*|*/test-ledger|\
|
||||
*/.anchor/*|*/.anchor|\
|
||||
*/.yarn/*|*/.yarn|\
|
||||
*/.vendor/*|*/.vendor|\
|
||||
*/.agents/*|*/.agents|\
|
||||
*/.codex/*|*/.codex|\
|
||||
*/.claude/*|*/.claude|\
|
||||
*/deploy/backup/archive/*|\
|
||||
*/scripts/*/runs/*|\
|
||||
*/scripts/*/keypairs/*|\
|
||||
*/keys/*|\
|
||||
*/.git-local-backup/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
local base="${p##*/}"
|
||||
local lower
|
||||
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$lower" in
|
||||
.env|.env.*|\
|
||||
.debug-token|\
|
||||
.npmrc|.pypirc|.netrc|\
|
||||
credentials|credentials.*|\
|
||||
secrets|secrets.*|\
|
||||
secret|secret.*|\
|
||||
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||
*keypair*.json|\
|
||||
service-account*.json|\
|
||||
firebase-adminsdk*.json|\
|
||||
google-services.json|\
|
||||
validator.log)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$lower" in
|
||||
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||
.ds_store)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||
else
|
||||
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||
fi
|
||||
|
||||
# Convert to project-relative paths and enforce hard deny rules.
|
||||
: > "$LIST"
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ "$f" = /* ]]; then
|
||||
rel="${f#"$ROOT"/}"
|
||||
else
|
||||
rel="$f"
|
||||
fi
|
||||
|
||||
[[ "$rel" == "$OUT" ]] && continue
|
||||
[[ -z "$rel" ]] && continue
|
||||
|
||||
if is_denied_path "$rel"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$LIST"
|
||||
done < "$TMP/files.z"
|
||||
|
||||
sort -u "$LIST" -o "$LIST"
|
||||
|
||||
# Content scan: fail closed on common credential/private-key patterns.
|
||||
# We scan only text-ish files; grep -I skips binary data.
|
||||
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||
|
||||
: > "$SAFE_LIST"
|
||||
found_secret=0
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -f "$ROOT/$rel" ]] || continue
|
||||
|
||||
# Files that contain examples/templates can legitimately mention secret keys
|
||||
# with placeholders. They are scanned too, but placeholder-looking values
|
||||
# are less likely to match the regex above.
|
||||
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||
echo "BLOCKED: possible secret in $rel" >&2
|
||||
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||
| head -n 3 >&2 || true
|
||||
found_secret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||
done < "$LIST"
|
||||
|
||||
if (( found_secret != 0 )); then
|
||||
echo >&2
|
||||
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||
echo "ERROR: no files left to bundle." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
rm -f -- "$OUT"
|
||||
|
||||
(
|
||||
cd "$ROOT"
|
||||
zip -q -9 "$OUT" -@ < "$SAFE_LIST"
|
||||
)
|
||||
|
||||
echo "Created: $OUT"
|
||||
echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')"
|
||||
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||
@@ -13,6 +13,9 @@
|
||||
- `user_access_servers_current`
|
||||
- `solana_user_pda_history`
|
||||
- источник истины по пользовательским PDA: `solana_user_pda_current`.
|
||||
- в `solana_user_pda_current` хранятся оба варианта логина:
|
||||
- `login` — display-логин из PDA;
|
||||
- `normalized_login` — канонический lower-case для runtime lookup и части FK;
|
||||
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
|
||||
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
||||
|
||||
@@ -56,7 +59,7 @@ psql \
|
||||
Скрипт:
|
||||
|
||||
- создаёт таблицу версии схемы `db_schema_version`;
|
||||
- ставит `schema_version = 2`;
|
||||
- ставит актуальный `schema_version`;
|
||||
- создаёт таблицы sync-модуля Solana users;
|
||||
- создаёт server runtime tables;
|
||||
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,10 +51,14 @@
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
|
||||
| `ListUserParams` | `10_User_Params_API.md` | список параметров пользователя |
|
||||
| `UpsertUserSetting` | `13_User_Settings_API.md` | запись пользовательской настройки |
|
||||
| `GetUserSetting` | `13_User_Settings_API.md` | чтение одной пользовательской настройки |
|
||||
| `ListUserSettings` | `13_User_Settings_API.md` | список пользовательских настроек |
|
||||
| `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья |
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
@@ -63,6 +67,8 @@
|
||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
|
||||
| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору |
|
||||
| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных |
|
||||
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||
@@ -71,7 +77,6 @@
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
|
||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||
|
||||
@@ -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 в списке каналов и в канале.
|
||||
@@ -0,0 +1,81 @@
|
||||
# API для разработчиков: уведомления
|
||||
|
||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
||||
|
||||
Текущая операция:
|
||||
|
||||
- `GetNotifications`
|
||||
|
||||
## 1. `GetNotifications`
|
||||
|
||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
||||
|
||||
Возвращаются две отдельные ленты:
|
||||
|
||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
@@ -19,6 +19,10 @@
|
||||
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||
|
||||
Важно: в текущей архитектуре у пользователя одновременно может быть не более
|
||||
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
|
||||
`synced` и пользовательских курсоров.
|
||||
|
||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
||||
@@ -39,6 +43,13 @@
|
||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||
- Дедупликация по глобальному номеру блока и хэшу.
|
||||
|
||||
### 3.3 Пользовательские настройки
|
||||
|
||||
- Отдельная таблица `user_settings`.
|
||||
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
|
||||
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
|
||||
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
|
||||
|
||||
## 4. Текущая реализованная схема
|
||||
|
||||
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
|
||||
|
||||
@@ -334,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.
|
||||
|
||||
+311
-6
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
/>
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
@@ -12,13 +12,13 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -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>
|
||||
|
||||
+149
-479
@@ -1,16 +1,17 @@
|
||||
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 { attachScrollToBottomButton } from './components/scroll-to-bottom-button.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';
|
||||
@@ -85,17 +86,17 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202607152145';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -173,14 +174,6 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50;
|
||||
const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
|
||||
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
||||
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
|
||||
const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']);
|
||||
const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72;
|
||||
const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56;
|
||||
const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35;
|
||||
const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14;
|
||||
const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32;
|
||||
const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18;
|
||||
const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260;
|
||||
|
||||
let currentCleanup = null;
|
||||
let pingIntervalId = null;
|
||||
@@ -202,10 +195,16 @@ let hiddenDmAudioUnlocked = false;
|
||||
let initialConnectionCompleted = false;
|
||||
let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
let currentMountState = null;
|
||||
let activeSwipePreview = null;
|
||||
const keepAliveEntries = new Map();
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
'messages-list',
|
||||
'chat-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-thread-view',
|
||||
'notifications-view',
|
||||
]);
|
||||
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
@@ -224,6 +223,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 +240,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 +312,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 +427,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 +1112,6 @@ function renderPageFailureFallback(pageId, error) {
|
||||
});
|
||||
|
||||
screenEl.innerHTML = '';
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -1432,8 +1147,15 @@ function renderPageFailureFallback(pageId, error) {
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function attachPageScrollToBottom(pageId, screen) {
|
||||
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
|
||||
|
||||
return attachScrollToBottomButton({
|
||||
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
|
||||
});
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
teardownSwipePreview({ cancelOnly: true });
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
@@ -1450,56 +1172,13 @@ function renderApp() {
|
||||
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
const rootPageId = resolveToolbarActive(pageId);
|
||||
const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId);
|
||||
const currentRoutePath = String(window.location.pathname || '/');
|
||||
|
||||
rememberToolbarRoute(pageId);
|
||||
|
||||
if (currentMountState) {
|
||||
const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId;
|
||||
if (shouldPreserveCurrent) {
|
||||
currentMountState.routePath = currentMountState.routePath || currentRoutePath;
|
||||
keepAliveEntries.set(currentMountState.rootPageId, currentMountState);
|
||||
detachMountedScreen(currentMountState);
|
||||
currentMountState = null;
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
} else {
|
||||
destroyMountState(currentMountState);
|
||||
if (currentMountState.keepAlive) {
|
||||
keepAliveEntries.delete(currentMountState.rootPageId);
|
||||
}
|
||||
currentMountState = null;
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
} else {
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
|
||||
const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null;
|
||||
if (cachedEntry && cachedEntry.routePath === currentRoutePath) {
|
||||
mountExistingEntry(cachedEntry, { showAppChrome, pageId });
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedEntry) {
|
||||
destroyMountState(cachedEntry);
|
||||
keepAliveEntries.delete(rootPageId);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1512,20 +1191,12 @@ function renderApp() {
|
||||
}
|
||||
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
currentMountState = {
|
||||
pageId,
|
||||
rootPageId,
|
||||
keepAlive: keepAliveEligible,
|
||||
routePath: currentRoutePath,
|
||||
screen,
|
||||
cleanup: currentCleanup,
|
||||
chrome,
|
||||
destroyed: false,
|
||||
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
||||
currentCleanup = () => {
|
||||
pageCleanup?.();
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
if (keepAliveEligible) {
|
||||
keepAliveEntries.set(rootPageId, currentMountState);
|
||||
}
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
@@ -2008,7 +1679,6 @@ async function init() {
|
||||
})();
|
||||
|
||||
window.addEventListener('popstate', renderApp);
|
||||
installHorizontalTabSwipe();
|
||||
document.addEventListener('pointerdown', () => {
|
||||
void unlockHiddenDmAudio();
|
||||
}, { passive: true });
|
||||
|
||||
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
|
||||
onSelect,
|
||||
selectedTxIds = [],
|
||||
historyOnly = false,
|
||||
persistToHistory = true,
|
||||
allowHistorySelection = true,
|
||||
allowExistingTxInput = true,
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
initialSha256 = '',
|
||||
initialName = '',
|
||||
fixedFile = false,
|
||||
autoOpenFileDialog = true,
|
||||
shineType = '',
|
||||
extraUploadTags = [],
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
|
||||
let selectedPreviewPriceInfo = null;
|
||||
let priceInfo = null;
|
||||
let balanceInfo = null;
|
||||
let autoOpenedFileDialog = false;
|
||||
let autoOpenedFileDialogOnce = false;
|
||||
const isAvatarMode = String(mode || '') === 'avatar';
|
||||
if (isAvatarMode && !String(uploadTransport || '').trim()) {
|
||||
selectedUploadTransport = 'turbo';
|
||||
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
|
||||
const historyPurposeMode = String(historyPurpose || '').trim();
|
||||
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||
const effectiveDialogTitle = String(dialogTitle || '').trim();
|
||||
const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim();
|
||||
const forcedShineType = String(shineType || '').trim();
|
||||
const normalizedExtraUploadTags = Array.isArray(extraUploadTags)
|
||||
? extraUploadTags.filter((item) => item?.name && item?.value)
|
||||
: [];
|
||||
if (initialFile instanceof File) {
|
||||
selectedFile = initialFile;
|
||||
if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isTurboUpload() {
|
||||
return selectedUploadTransport === 'turbo';
|
||||
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
});
|
||||
const item = persistToHistory
|
||||
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
markPlaced: false,
|
||||
})
|
||||
: {
|
||||
...attachment,
|
||||
...normalizeAttachment(attachment),
|
||||
};
|
||||
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
||||
close(resolve, item);
|
||||
}
|
||||
@@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({
|
||||
|
||||
const showUpload = async () => {
|
||||
const turboMode = isTurboUpload();
|
||||
const titleText = effectiveDialogTitle
|
||||
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|
||||
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
||||
const canShowHistory = allowHistorySelection;
|
||||
const canShowExisting = allowExistingTxInput;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
|
||||
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
@@ -611,8 +643,8 @@ export function openArweaveAttachmentManager({
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
`}
|
||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -622,7 +654,7 @@ export function openArweaveAttachmentManager({
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
@@ -818,27 +850,30 @@ export function openArweaveAttachmentManager({
|
||||
storagePwd: cleanStoragePwd,
|
||||
keySource: selectedTurboKeySource,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
})
|
||||
: await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
jwk: selectedWallet()?.jwk,
|
||||
file: selectedFile,
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
...normalizedExtraUploadTags,
|
||||
],
|
||||
});
|
||||
finish(resolve, {
|
||||
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
||||
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
|
||||
size: selectedFile.size,
|
||||
sha256: selectedSha256,
|
||||
ar: uploaded.id,
|
||||
uploadTransport: turboMode ? 'turbo' : 'arweave',
|
||||
preview: previewUpload?.id && selectedPreviewSha256
|
||||
? {
|
||||
ar: previewUpload.id,
|
||||
@@ -854,8 +889,19 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
});
|
||||
|
||||
if (!autoOpenedFileDialog) {
|
||||
autoOpenedFileDialog = true;
|
||||
if (selectedFile && selectedSha256) {
|
||||
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
|
||||
previewOptionEl.hidden = false;
|
||||
}
|
||||
if (turboMode) {
|
||||
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||
} else {
|
||||
await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixedFile && autoOpenFileDialog && !autoOpenedFileDialogOnce) {
|
||||
autoOpenedFileDialogOnce = true;
|
||||
window.setTimeout(() => fileEl?.click(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
if (sizeClass) classes.add(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||
if (glow) classes.add('avatar-glow');
|
||||
wrap.className = Array.from(classes).join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.className = 'avatar-photo';
|
||||
img.alt = String(alt || 'Аватар');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
wrap.append(img);
|
||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
return renderAvatar({
|
||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
alt: 'Аватар',
|
||||
glow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header';
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function createOverflowDots({ className = '' } = {}) {
|
||||
const dots = document.createElement('span');
|
||||
const extra = String(className || '').trim();
|
||||
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
||||
dots.setAttribute('aria-hidden', 'true');
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
dots.append(document.createElement('i'));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function attachScrollToBottomButton({
|
||||
scrollContainer,
|
||||
mountTarget = document.querySelector('.app-shell'),
|
||||
thresholdPx = 160,
|
||||
title = 'Вниз',
|
||||
} = {}) {
|
||||
const target = resolveElement(mountTarget);
|
||||
if (!(target instanceof Element)) {
|
||||
return {
|
||||
button: null,
|
||||
refresh() {},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'scroll-to-bottom-btn';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||
button.tabIndex = -1;
|
||||
button.append(buildArrowIcon());
|
||||
target.append(button);
|
||||
|
||||
let disposed = false;
|
||||
let boundContainer = null;
|
||||
let resizeObserver = null;
|
||||
let mutationObserver = null;
|
||||
let refreshFrame = 0;
|
||||
|
||||
const hide = () => {
|
||||
button.classList.remove('is-visible');
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.tabIndex = -1;
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (disposed || refreshFrame) return;
|
||||
refreshFrame = window.requestAnimationFrame(() => {
|
||||
refreshFrame = 0;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const unbindContainer = () => {
|
||||
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
boundContainer = null;
|
||||
};
|
||||
|
||||
const bindContainer = () => {
|
||||
const nextContainer = resolveElement(scrollContainer);
|
||||
if (!(nextContainer instanceof Element)) {
|
||||
if (boundContainer) unbindContainer();
|
||||
return null;
|
||||
}
|
||||
if (nextContainer === boundContainer) return boundContainer;
|
||||
|
||||
unbindContainer();
|
||||
boundContainer = nextContainer;
|
||||
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(scheduleRefresh);
|
||||
resizeObserver.observe(boundContainer);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
mutationObserver = new MutationObserver(scheduleRefresh);
|
||||
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return boundContainer;
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
if (disposed) return;
|
||||
const container = bindContainer();
|
||||
if (!container) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollHeight = Number(container.scrollHeight || 0);
|
||||
const clientHeight = Number(container.clientHeight || 0);
|
||||
const scrollTop = Number(container.scrollTop || 0);
|
||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
||||
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
||||
|
||||
button.classList.toggle('is-visible', shouldShow);
|
||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||
button.tabIndex = shouldShow ? 0 : -1;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = bindContainer();
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
scheduleRefresh();
|
||||
};
|
||||
|
||||
button.addEventListener('click', scrollToBottom);
|
||||
window.addEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
||||
window.requestAnimationFrame(refresh);
|
||||
|
||||
return {
|
||||
button,
|
||||
refresh: scheduleRefresh,
|
||||
cleanup() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (refreshFrame) {
|
||||
window.cancelAnimationFrame(refreshFrame);
|
||||
refreshFrame = 0;
|
||||
}
|
||||
unbindContainer();
|
||||
window.removeEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
||||
button.removeEventListener('click', scrollToBottom);
|
||||
button.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js';
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
|
||||
@@ -33,7 +33,7 @@ function getTotalUnreadMessages() {
|
||||
|
||||
function navigateWithGuestRules(pageId, navigate) {
|
||||
if (state.session.isAuthorized) {
|
||||
navigate(getToolbarNavigationTarget(pageId));
|
||||
navigate(pageId);
|
||||
return;
|
||||
}
|
||||
if (pageId === 'messages-list') {
|
||||
@@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(getToolbarNavigationTarget(pageId));
|
||||
navigate(pageId);
|
||||
}
|
||||
|
||||
export function renderToolbar(currentPageId, navigate) {
|
||||
@@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
btn.append(badge);
|
||||
}
|
||||
if (item.pageId === 'channels-list') {
|
||||
btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list')));
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
openArweaveAttachmentManager,
|
||||
markArweaveAttachmentPlaced,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
const label = String(title || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
if (avatar?.ar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readArweaveAttachmentHistory,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
@@ -73,13 +74,14 @@ export function render({ navigate }) {
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню">⋮</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [],
|
||||
rightActions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
const headerLeft = header.querySelector('.header-left');
|
||||
let threadHeaderButton = null;
|
||||
if (headerLeft) {
|
||||
const channelsListButton = document.createElement('button');
|
||||
channelsListButton.type = 'button';
|
||||
channelsListButton.className = 'icon-btn';
|
||||
channelsListButton.textContent = '↑';
|
||||
channelsListButton.title = 'К списку каналов';
|
||||
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
||||
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
||||
headerLeft.append(channelsListButton);
|
||||
|
||||
threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
headerLeft.append(threadHeaderButton);
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
@@ -234,6 +233,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;
|
||||
@@ -381,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
if (txId) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: txId ? { ar: txId } : null,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -1249,6 +1444,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 +1506,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 +1558,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 +1576,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 +1636,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 +1649,8 @@ async function loadFromApi(route, channelId) {
|
||||
posts,
|
||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||
reverseChannelMissingWarning,
|
||||
unreadCount,
|
||||
messagesCount,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
selector,
|
||||
@@ -1733,6 +1939,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 +2107,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 +2136,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 +2158,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 +2216,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) {
|
||||
@@ -2033,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: channelHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
],
|
||||
});
|
||||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
@@ -2056,6 +2293,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
};
|
||||
let activeSelector = null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
function createChannelAvatar(channel = {}) {
|
||||
return renderAvatar({
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'small',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
const row = document.createElement('article');
|
||||
row.className = 'channel-row';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||||
<div class="channel-row-main">
|
||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.prepend(createChannelAvatar(channel));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onSubscribeChannel,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 320;
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ divider: true },
|
||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ divider: true },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ label: 'Создать канал', action: () => onSubscribeChannel?.() },
|
||||
{ divider: true },
|
||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
@@ -1206,20 +1213,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
if (channel.avaAr) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||
avatar.append(img);
|
||||
} else {
|
||||
avatar.textContent = channel.avatar;
|
||||
}
|
||||
const avatar = createChannelAvatar(channel);
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channel-row-controls';
|
||||
|
||||
@@ -1230,37 +1227,10 @@ 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.hidden = 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);
|
||||
}
|
||||
controls.append(time, count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
@@ -1373,43 +1343,18 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.type = 'button';
|
||||
backBtn.className = 'icon-btn channels-top-back-btn';
|
||||
backBtn.textContent = '←';
|
||||
backBtn.setAttribute('aria-label', 'Назад');
|
||||
backBtn.addEventListener('click', () => navigateBack());
|
||||
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
|
||||
const findChannelBtn = document.createElement('button');
|
||||
findChannelBtn.type = 'button';
|
||||
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
||||
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
||||
findChannelBtn.title = 'Найти канал';
|
||||
const findChannelIcon = document.createElement('span');
|
||||
findChannelIcon.className = 'channels-search-icon';
|
||||
findChannelIcon.setAttribute('aria-hidden', 'true');
|
||||
findChannelBtn.append(findChannelIcon);
|
||||
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||
|
||||
const createInMyBtn = document.createElement('button');
|
||||
createInMyBtn.type = 'button';
|
||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||
createInMyBtn.textContent = '+';
|
||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
const topMenuBtn = document.createElement('button');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.textContent = '⋮';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
@@ -1418,18 +1363,11 @@ export function render({ navigate, route, chrome }) {
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
onSubscribeChannel: () => openSimpleSubscribeModal({
|
||||
kind: 'channel',
|
||||
kindLabel: 'Добавить канал',
|
||||
submitLabel: 'Добавить',
|
||||
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
topBarLeft.append(backBtn, topTitle);
|
||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topBarRight);
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
@@ -1450,10 +1388,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
findChannelBtn.style.display = '';
|
||||
createInMyBtn.style.display = '';
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
+143
-61
@@ -1,4 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -27,10 +29,55 @@ import {
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function createChatHeaderParts(login) {
|
||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||
|
||||
const avatarSlot = document.createElement('span');
|
||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||
const initialAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.append(initialAvatar);
|
||||
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'chat-header-login';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
loginEl.textContent = cleanLogin;
|
||||
|
||||
void loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
if (!avatarSlot.isConnected) return;
|
||||
const upgradedAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName: String(snapshot?.firstName || '').trim(),
|
||||
lastName: String(snapshot?.lastName || '').trim(),
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.replaceChildren(upgradedAvatar);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { centerNode: loginEl, avatarSlot };
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
@@ -572,12 +619,27 @@ function scrollToUnreadSeparator(list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
|
||||
function renderLog(
|
||||
list,
|
||||
chatId,
|
||||
{
|
||||
onOpenActions,
|
||||
markAsRead = true,
|
||||
scrollMode = 'latest',
|
||||
showUnreadSeparator = true,
|
||||
unreadSeparatorMessageKey = '',
|
||||
} = {},
|
||||
) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||
messages.forEach((msg) => {
|
||||
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
|
||||
const isUnreadBoundary = showUnreadSeparator
|
||||
&& !unreadSeparatorInserted
|
||||
&& separatorMessageKey
|
||||
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
|
||||
if (isUnreadBoundary) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'chat-unread-separator';
|
||||
const label = document.createElement('span');
|
||||
@@ -781,12 +843,8 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
}
|
||||
}
|
||||
|
||||
function setChatKeyboardOpen(isOpen) {
|
||||
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
|
||||
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
document.body.classList.add('chat-topbar-overlay');
|
||||
const routeChatId = route.params.chatId || 'u1';
|
||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
||||
@@ -800,12 +858,53 @@ export function render({ navigate, route, chrome }) {
|
||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||
let historyHasMore = true;
|
||||
let historyLoading = false;
|
||||
let historyNextBeforeTimeMs = 0;
|
||||
let historyNextBeforeMessageKey = '';
|
||||
let historyBootstrapped = false;
|
||||
let boundScrollContainer = null;
|
||||
let unreadSeparatorVisible = hasUnreadIncoming;
|
||||
let unreadSeparatorHideTimer = null;
|
||||
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
||||
|
||||
const clearUnreadSeparatorHideTimer = () => {
|
||||
if (unreadSeparatorHideTimer) {
|
||||
window.clearTimeout(unreadSeparatorHideTimer);
|
||||
unreadSeparatorHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead,
|
||||
scrollMode,
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
};
|
||||
|
||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorVisible = false;
|
||||
if (rerender) {
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUnreadSeparatorAutoHide = () => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorHideTimer = window.setTimeout(() => {
|
||||
hideUnreadSeparator({ rerender: true });
|
||||
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
|
||||
};
|
||||
|
||||
const handleReadAloud = async (msg) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
@@ -819,13 +918,13 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleStartCall = async (mode = 'audio') => {
|
||||
try {
|
||||
await startOutgoingCall(chatId, { mode });
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -848,17 +947,12 @@ export function render({ navigate, route, chrome }) {
|
||||
unread: false,
|
||||
rawBlobB64: String(result?.localBlobB64 || ''),
|
||||
});
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: 'latest',
|
||||
});
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
notifyUnreadStateUpdated();
|
||||
};
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
|
||||
const historyLoader = document.createElement('div');
|
||||
historyLoader.className = 'dm-history-loader';
|
||||
historyLoader.hidden = true;
|
||||
@@ -872,9 +966,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
{
|
||||
@@ -885,7 +979,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onClick: () => handleStartCall('audio'),
|
||||
},
|
||||
{
|
||||
label: '⋮',
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
@@ -942,8 +1036,9 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
@@ -1010,29 +1105,6 @@ export function render({ navigate, route, chrome }) {
|
||||
let inputFocused = false;
|
||||
let emojiPickerOpen = false;
|
||||
let emojiSelection = null;
|
||||
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
const appShell = document.querySelector('.app-shell');
|
||||
|
||||
const setKeyboardInset = (valuePx = 0) => {
|
||||
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
|
||||
};
|
||||
|
||||
const syncKeyboardUi = () => {
|
||||
const viewport = window.visualViewport || null;
|
||||
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
|
||||
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
|
||||
const keyboardInset = viewportShrunk
|
||||
? Math.max(0, baseViewportHeight - viewportHeight)
|
||||
: 0;
|
||||
setKeyboardInset(keyboardInset);
|
||||
setChatKeyboardOpen(inputFocused && viewportShrunk);
|
||||
if (viewportShrunk && window.scrollY !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
if (inputFocused) {
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
}
|
||||
};
|
||||
|
||||
const setHistoryLoadingState = (isLoading) => {
|
||||
historyLoader.hidden = !isLoading;
|
||||
@@ -1235,20 +1307,21 @@ export function render({ navigate, route, chrome }) {
|
||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
}
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||
const text = safeText.trim();
|
||||
if (!text) return;
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
const editing = activeEdit;
|
||||
const replying = !editing ? activeReply : null;
|
||||
const finalText = editing
|
||||
? `${String(editing?.prefixText || '')}${text}`
|
||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
|
||||
try {
|
||||
@@ -1288,7 +1361,7 @@ export function render({ navigate, route, chrome }) {
|
||||
cancelReplyMode({ restoreDraft: false });
|
||||
}
|
||||
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
if (localRevisionApplied) {
|
||||
notifyUnreadStateUpdated();
|
||||
}
|
||||
@@ -1331,7 +1404,7 @@ export function render({ navigate, route, chrome }) {
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1393,7 +1466,7 @@ export function render({ navigate, route, chrome }) {
|
||||
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
|
||||
historyBootstrapped = true;
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
||||
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
||||
if (preserveScroll) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
|
||||
@@ -1445,12 +1518,12 @@ export function render({ navigate, route, chrome }) {
|
||||
input?.addEventListener('focus', () => {
|
||||
rememberEmojiSelection();
|
||||
inputFocused = true;
|
||||
syncKeyboardUi();
|
||||
scrollToLatestMessage(log);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (inputFocused) scrollToLatestMessage(log);
|
||||
});
|
||||
});
|
||||
input?.addEventListener('blur', () => {
|
||||
inputFocused = false;
|
||||
setChatKeyboardOpen(false);
|
||||
});
|
||||
emojiToggle?.setAttribute('aria-expanded', 'false');
|
||||
emojiToggle?.addEventListener('pointerdown', (event) => {
|
||||
@@ -1493,25 +1566,34 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleIncomingChatRefresh = async (event) => {
|
||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (Number(event?.detail?.messageType || 0) === 1) {
|
||||
if (!unreadSeparatorVisible) {
|
||||
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
||||
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
||||
}
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
preserveComposerSelection(input, () => {
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
|
||||
renderChatLog({ scrollMode: 'latest' });
|
||||
});
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
};
|
||||
|
||||
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
||||
window.addEventListener('resize', syncKeyboardUi);
|
||||
|
||||
chrome?.setComposer(form);
|
||||
wrap.append(historyLoader, log);
|
||||
screen.append(wrap);
|
||||
chrome?.setComposer(form);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (markChatRead(chatId) > 0) {
|
||||
notifyUnreadStateUpdated();
|
||||
@@ -1519,18 +1601,18 @@ export function render({ navigate, route, chrome }) {
|
||||
}, 220);
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
window.requestAnimationFrame(() => {
|
||||
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
|
||||
boundScrollContainer = wrap;
|
||||
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
|
||||
void loadHistoryPage({ preserveScroll: true });
|
||||
});
|
||||
screen.cleanup = () => {
|
||||
setChatKeyboardOpen(false);
|
||||
setKeyboardInset(0);
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
stopAllTwemojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
||||
window.removeEventListener('resize', syncKeyboardUi);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
chrome?.setComposer(null);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
getChatMessages,
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
const dmAvatarPendingByLogin = new Map();
|
||||
|
||||
const RELATION_ORDER = new Map([
|
||||
['close_friend', 0],
|
||||
['contact', 1],
|
||||
['none', 2],
|
||||
]);
|
||||
|
||||
async function loadDmAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
@@ -37,13 +45,14 @@ async function loadDmAvatarSnapshot(login) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createDmAvatar(login) {
|
||||
function createDmAvatar(login, { className = '' } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||
@@ -58,6 +67,7 @@ function createDmAvatar(login) {
|
||||
: null,
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
upgraded.classList.add('avatar');
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -65,10 +75,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,13 +154,18 @@ function formatChatRowTime(ts) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||
function compareChatRows(a, b) {
|
||||
const timeA = Number(a?.lastMessageTimeMs || 0);
|
||||
const timeB = Number(b?.lastMessageTimeMs || 0);
|
||||
if (timeA !== timeB) return timeB - timeA;
|
||||
const nameA = String(a?.peerLogin || '').toLowerCase();
|
||||
const nameB = String(b?.peerLogin || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB, 'ru');
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const login = String(state.session.login || '').trim();
|
||||
|
||||
const head = document.createElement('header');
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
@@ -98,23 +175,113 @@ 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"></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 menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||
// land on the content layer underneath. Render the open menu as a body portal.
|
||||
menuTemplate?.remove();
|
||||
|
||||
let menuPortal = null;
|
||||
|
||||
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 +290,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 +305,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 +327,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 +424,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;
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
||||
let persistedCenterLogin = '';
|
||||
let persistedCenterHistory = [];
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
|
||||
else window.history.replaceState({}, '', nextPath);
|
||||
}
|
||||
|
||||
function setBackButtonState(backBtn) {
|
||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||
backBtn.disabled = centerHistory.length === 0;
|
||||
}
|
||||
|
||||
|
||||
function openSearchModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
if (requestId !== loadSeq) return;
|
||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (!centerHistory.length) return;
|
||||
const prev = centerHistory.pop();
|
||||
if (!prev) {
|
||||
setBackButtonState(backBtnEl);
|
||||
return;
|
||||
}
|
||||
void load(prev, { pushHistory: false });
|
||||
},
|
||||
},
|
||||
rightActions: [
|
||||
{ label: 'Найти', onClick: openSearchModal },
|
||||
],
|
||||
});
|
||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||
setBackButtonState(backBtnEl);
|
||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||
header.classList.add('network-topbar');
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
|
||||
window.setTimeout(() => openSearchModal(), 0);
|
||||
}
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
|
||||
filterBar.append(chip);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header, filterBar);
|
||||
chrome?.setTopbar(header);
|
||||
stage.append(board, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,394 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
const CONNECTION_CLOSE_FRIEND = 10;
|
||||
const profileSnapshotCache = new Map();
|
||||
const profileSnapshotPending = new Map();
|
||||
|
||||
function connectionTypeLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND:
|
||||
return 'близкие друзья';
|
||||
default:
|
||||
return 'новую связь';
|
||||
}
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||
|
||||
function renderList(container) {
|
||||
const active = state.notificationsTab;
|
||||
container.innerHTML = '';
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'meta-muted';
|
||||
description.textContent = active === 'events'
|
||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
||||
|
||||
card.append(title, description, note);
|
||||
container.append(card);
|
||||
function normalizeItem(item) {
|
||||
return {
|
||||
kind: String(item?.kind || ''),
|
||||
createdAtMs: Number(item?.createdAtMs || 0),
|
||||
sourceLogin: String(item?.sourceLogin || ''),
|
||||
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
||||
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
||||
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
||||
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
||||
sourceText: String(item?.sourceText || ''),
|
||||
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
||||
targetLogin: String(item?.targetLogin || ''),
|
||||
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
||||
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
||||
targetBlockHash: String(item?.targetBlockHash || ''),
|
||||
profile: null,
|
||||
engagement: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ chrome } = {}) {
|
||||
function formatRelativeTime(value) {
|
||||
const ts = Number(value || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - ts);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
|
||||
if (diffMs < minute) return 'сейчас';
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
||||
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
||||
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
||||
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
||||
}
|
||||
|
||||
function profileField(snapshot, key) {
|
||||
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
||||
.find((field) => String(field?.key || '') === key);
|
||||
return String(row?.value || '').trim();
|
||||
}
|
||||
|
||||
async function loadCachedProfileSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
|
||||
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
|
||||
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
profileSnapshotCache.set(key, snapshot || null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
profileSnapshotCache.set(key, null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return null;
|
||||
});
|
||||
|
||||
profileSnapshotPending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function normalizeEngagement(source) {
|
||||
if (!source || typeof source !== 'object') return null;
|
||||
const likesCount = Math.max(0, Number(source.likesCount || 0));
|
||||
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
|
||||
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
|
||||
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
|
||||
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
|
||||
|
||||
const result = {
|
||||
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
|
||||
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
|
||||
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
|
||||
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
|
||||
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
|
||||
};
|
||||
|
||||
return Object.values(result).some((count) => count > 0) ? result : null;
|
||||
}
|
||||
|
||||
async function loadSourceEngagement(item) {
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
const blockHash = String(item?.sourceBlockHash || '').trim();
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
|
||||
try {
|
||||
const payload = await authService.getMessageThread(
|
||||
{ blockchainName, blockNumber, blockHash },
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
String(state.session.login || '').trim(),
|
||||
);
|
||||
return normalizeEngagement(payload?.focus);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichItem(item, activeTab) {
|
||||
const [profile, engagement] = await Promise.all([
|
||||
loadCachedProfileSnapshot(item.sourceLogin),
|
||||
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
|
||||
]);
|
||||
return { ...item, profile, engagement };
|
||||
}
|
||||
|
||||
function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events'
|
||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderIdentity(item) {
|
||||
const profile = item.profile;
|
||||
const firstName = profileField(profile, 'first_name');
|
||||
const lastName = profileField(profile, 'last_name');
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
||||
const avatar = profile?.avatar?.txId
|
||||
? {
|
||||
ar: String(profile.avatar.txId || '').trim(),
|
||||
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notification-identity';
|
||||
header.append(renderUserAvatar({
|
||||
login: item.sourceLogin || 'unknown',
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'notification-identity-text';
|
||||
|
||||
const primary = document.createElement('div');
|
||||
primary.className = 'notification-identity-primary';
|
||||
const name = document.createElement('strong');
|
||||
name.className = 'notification-person-name';
|
||||
name.textContent = fullName;
|
||||
primary.append(name);
|
||||
|
||||
const login = String(item.sourceLogin || '').trim();
|
||||
if (login) {
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'notification-login';
|
||||
loginEl.textContent = `@${login}`;
|
||||
primary.append(loginEl);
|
||||
}
|
||||
|
||||
const relative = formatRelativeTime(item.createdAtMs);
|
||||
if (relative) {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'notification-time-separator';
|
||||
separator.textContent = '·';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-time';
|
||||
time.textContent = relative;
|
||||
primary.append(separator, time);
|
||||
}
|
||||
|
||||
text.append(primary);
|
||||
header.append(text);
|
||||
return header;
|
||||
}
|
||||
|
||||
function renderEngagement(engagement) {
|
||||
if (!engagement) return null;
|
||||
|
||||
const stats = [
|
||||
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||
|
||||
if (!stats.length) return null;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'notification-engagement';
|
||||
stats.forEach(({ key, icon, label }) => {
|
||||
const stat = document.createElement('span');
|
||||
stat.className = 'notification-engagement-item';
|
||||
stat.title = label;
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'notification-engagement-icon';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconEl.textContent = icon;
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'notification-engagement-count';
|
||||
countEl.textContent = String(engagement[key]);
|
||||
stat.append(iconEl, countEl);
|
||||
row.append(stat);
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function notificationRoute(item, activeTab) {
|
||||
if (activeTab === 'events') {
|
||||
const login = String(item?.sourceLogin || '').trim();
|
||||
return login ? makeProfileRoute(login) : '';
|
||||
}
|
||||
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
|
||||
|
||||
return makeShineMessageRoute({
|
||||
messageBlockchainName: blockchainName,
|
||||
messageBlockNumber: blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function bindNotificationNavigation(row, routePath, navigate) {
|
||||
if (!routePath || typeof navigate !== 'function') return;
|
||||
|
||||
row.classList.add('notification-card--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'link');
|
||||
|
||||
const open = () => navigate(routePath);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
open();
|
||||
});
|
||||
}
|
||||
|
||||
function renderItem(item, activeTab, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'card stack notification-card';
|
||||
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||
row.append(renderIdentity(item));
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'events') {
|
||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
row.append(action);
|
||||
|
||||
if (activeTab === 'replies') {
|
||||
const body = document.createElement('p');
|
||||
body.className = 'notification-content';
|
||||
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||
row.append(body);
|
||||
|
||||
const engagement = renderEngagement(item.engagement);
|
||||
if (engagement) row.append(engagement);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.innerHTML = `
|
||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
||||
data-tab="replies"
|
||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
>Ответы</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
||||
data-tab="events"
|
||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
>События</button>
|
||||
`;
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
renderList(list);
|
||||
|
||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
let requestSeq = 0;
|
||||
|
||||
async function load() {
|
||||
const seq = ++requestSeq;
|
||||
const activeTab = state.notificationsTab;
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
|
||||
try {
|
||||
const payload = await authService.getNotifications(50);
|
||||
if (seq !== requestSeq) return;
|
||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
||||
.map(normalizeItem);
|
||||
if (!baseItems.length) {
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
||||
if (seq !== requestSeq) return;
|
||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось загрузить уведомления';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
||||
card.append(title, text);
|
||||
list.replaceChildren(card);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveNotificationTab(nextTab) {
|
||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||||
state.notificationsTab = normalizedTab;
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||||
const selected = node.dataset.tab === normalizedTab;
|
||||
node.classList.toggle('is-active', selected);
|
||||
node.dataset.selected = selected ? 'true' : 'false';
|
||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||||
setActiveNotificationTab(state.notificationsTab);
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.notificationsTab = btn.dataset.tab;
|
||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderList(list);
|
||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||
if (state.notificationsTab === nextTab) {
|
||||
setActiveNotificationTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNotificationTab(nextTab);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-actions profile-top-actions">
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const topActions = topbar.querySelector('.profile-top-actions');
|
||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
if (!profileMenuButton || profileMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const goTo = (route) => {
|
||||
closeProfileMenu();
|
||||
navigate(route);
|
||||
};
|
||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (profileMenuPortal) closeProfileMenu();
|
||||
else openProfileMenu();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenuPortal) return;
|
||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||
closeProfileMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||
closeProfileMenu();
|
||||
profileMenuButton?.focus();
|
||||
});
|
||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user