SHA256
Compare commits
35
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 |
@@ -98,14 +98,9 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 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_CONTACT = 20;
|
public static final short CONNECTION_CONTACT = 20;
|
||||||
|
|||||||
+3
-3
@@ -66,7 +66,7 @@ import java.util.Objects;
|
|||||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||||
*
|
*
|
||||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||||
*
|
*
|
||||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
private static boolean isValidSubType(short st) {
|
||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|||||||
+4
-1
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
|
|||||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
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". */
|
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||||
|
|
||||||
@@ -31,4 +34,4 @@ public final class ShineSignatureConstants {
|
|||||||
|
|
||||||
/** Длина публичного ключа Ed25519. */
|
/** Длина публичного ключа Ed25519. */
|
||||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_6 = 6;
|
public static final int SCHEMA_VERSION_6 = 6;
|
||||||
public static final int SCHEMA_VERSION_7 = 7;
|
public static final int SCHEMA_VERSION_7 = 7;
|
||||||
public static final int SCHEMA_VERSION_8 = 8;
|
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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -33,6 +36,9 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.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_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.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() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -51,10 +57,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
public static final short REACTION_UNLIKE = 2;
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
|
||||||
|
|
||||||
public static final short CONNECTION_CONTACT = 20;
|
public static final short CONNECTION_CONTACT = 20;
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
@@ -124,6 +128,19 @@ public final class DatabaseInitializer {
|
|||||||
}
|
}
|
||||||
if (currentVersion < SCHEMA_VERSION_8) {
|
if (currentVersion < SCHEMA_VERSION_8) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
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;
|
package shine.db;
|
||||||
|
|
||||||
import shine.db.connection.DbProvider;
|
import shine.db.connection.DbProvider;
|
||||||
|
import shine.db.dao.DmDialogStateDAO;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
|
|||||||
private static volatile DbController instance;
|
private static volatile DbController instance;
|
||||||
|
|
||||||
private final PostgresDbController delegate;
|
private final PostgresDbController delegate;
|
||||||
|
private volatile boolean dmDialogStateBootstrapped;
|
||||||
|
|
||||||
private DbController() {
|
private DbController() {
|
||||||
this.delegate = PostgresDbController.getInstance();
|
this.delegate = PostgresDbController.getInstance();
|
||||||
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
|
|||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
synchronized (DbController.class) {
|
synchronized (DbController.class) {
|
||||||
if (instance == null) {
|
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() {
|
public void close() {
|
||||||
delegate.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) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
* Совпадает с ConnectionBody:
|
* Совпадает с 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
|
* 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
|
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
|
||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 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_CONTACT = 20;
|
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 {
|
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE message_stats
|
UPDATE message_stats
|
||||||
SET likes_count = MAX(
|
SET likes_count = GREATEST(
|
||||||
0,
|
0,
|
||||||
likes_count - (
|
likes_count - COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)::int
|
||||||
FROM reactions_state rs
|
FROM reactions_state rs
|
||||||
WHERE rs.from_bch_name = ?
|
WHERE rs.from_bch_name = ?
|
||||||
AND rs.reaction_type = ?
|
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_number = message_stats.to_block_number
|
||||||
AND rs.to_block_hash = message_stats.to_block_hash
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
AND rs.to_bch_name <> ?
|
AND rs.to_bch_name <> ?
|
||||||
)
|
), 0)
|
||||||
)
|
)
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE message_stats
|
UPDATE message_stats
|
||||||
SET replies_count = MAX(
|
SET replies_count = GREATEST(
|
||||||
0,
|
0,
|
||||||
replies_count - COALESCE((
|
replies_count - COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)::int
|
||||||
FROM blocks b
|
FROM blocks b
|
||||||
WHERE b.bch_name = ?
|
WHERE b.bch_name = ?
|
||||||
AND b.msg_type = 1
|
AND b.msg_type = 1
|
||||||
|
|||||||
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
|
|||||||
return out;
|
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.
|
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
markMessageReadByReceipt(c, e);
|
markMessageReadByReceipt(c, e);
|
||||||
|
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
|
|||||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||||
markMessageReadByReceipt(c, first);
|
markMessageReadByReceipt(c, first);
|
||||||
markMessageReadByReceipt(c, second);
|
markMessageReadByReceipt(c, second);
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||||
c.commit();
|
c.commit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
|
|||||||
markMessageReadByReceipt(c, outgoing);
|
markMessageReadByReceipt(c, outgoing);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
|
|||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
markMessageReadByReceipt(c, incoming);
|
markMessageReadByReceipt(c, incoming);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
|
|||||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||||
upsertMessage(c, tombstone);
|
upsertMessage(c, tombstone);
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
|
|||||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||||
upsertMessage(c, tombstone);
|
upsertMessage(c, tombstone);
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
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; }
|
||||||
|
}
|
||||||
@@ -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,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)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 8, 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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -401,6 +401,44 @@ CREATE TABLE IF NOT EXISTS users_params (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_users_params_login
|
CREATE INDEX IF NOT EXISTS idx_users_params_login
|
||||||
ON 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 (
|
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
geo TEXT,
|
geo TEXT,
|
||||||
@@ -713,6 +751,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||||
ON signed_message_session_delivery(session_id, delivered);
|
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 (
|
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||||
owner_login TEXT NOT NULL,
|
owner_login TEXT NOT NULL,
|
||||||
remote_server_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_GetUserParam_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_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.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 ---
|
// --- NEW: connections friends lists ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
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_GetUserConnectionsGraph_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_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.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_GetUserConnectionsGraph_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_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.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_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_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_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_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_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_DmSyncBatch_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_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_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_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_SendMessagePair_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_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_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_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_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_DmSyncBatch_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_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_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_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("GetUserParam", new Net_GetUserParam_Handler()),
|
||||||
Map.entry("ListUserParams", new Net_ListUserParams_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 ---
|
// --- connections ---
|
||||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_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("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
|
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
@@ -204,6 +220,8 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
Map.entry("DmSyncBatch", new Net_DmSyncBatch_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("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_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("GetUserParam", Net_GetUserParam_Request.class),
|
||||||
Map.entry("ListUserParams", Net_ListUserParams_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 ---
|
// --- connections ---
|
||||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_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("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
|
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
@@ -286,6 +310,8 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||||
Map.entry("DmSyncBatch", Net_DmSyncBatch_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("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
|
|||||||
+69
-2
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
|||||||
import blockchain.MsgSubType;
|
import blockchain.MsgSubType;
|
||||||
import blockchain.body.BodyHasLine;
|
import blockchain.body.BodyHasLine;
|
||||||
import blockchain.body.BodyHasTarget;
|
import blockchain.body.BodyHasTarget;
|
||||||
|
import blockchain.body.ConnectionBody;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.StatusActionBody;
|
import blockchain.body.StatusActionBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
import blockchain.body.TextLineBody;
|
import blockchain.body.TextLineBody;
|
||||||
import blockchain.body.UserParamBody;
|
import blockchain.body.UserParamBody;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
|
|||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
import shine.db.dao.ChannelNameStateDAO;
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
import shine.db.dao.UserParamsDAO;
|
import shine.db.dao.UserParamsDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
import shine.db.entities.ChannelNameStateEntry;
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
|
import shine.db.entities.UserNotificationEntry;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
|
||||||
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
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() {
|
public Net_AddBlock_Handler() {
|
||||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
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) {
|
if (chat200CreateSeed != null) {
|
||||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||||
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return b;
|
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) {
|
private static String toHex(byte[] bytes) {
|
||||||
if (bytes == null) return "null";
|
if (bytes == null) return "null";
|
||||||
char[] HEX = "0123456789abcdef".toCharArray();
|
char[] HEX = "0123456789abcdef".toCharArray();
|
||||||
|
|||||||
+14
-2
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
|
|||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
import shine.db.dao.ChannelNameStateDAO;
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
import shine.db.dao.UserParamsDAO;
|
import shine.db.dao.UserParamsDAO;
|
||||||
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
import shine.db.entities.ChannelNameStateEntry;
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
|
import shine.db.entities.UserNotificationEntry;
|
||||||
import utils.files.FileStoreUtil;
|
import utils.files.FileStoreUtil;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
|
|||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
private final ChannelNameStateDAO channelNameStateDAO;
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
private final UserParamsDAO userParamsDAO;
|
private final UserParamsDAO userParamsDAO;
|
||||||
|
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||||
|
|
||||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||||
BlockchainStateDAO stateDAO,
|
BlockchainStateDAO stateDAO,
|
||||||
UserParamsDAO userParamsDAO,
|
UserParamsDAO userParamsDAO,
|
||||||
ChannelNameStateDAO channelNameStateDAO) {
|
ChannelNameStateDAO channelNameStateDAO,
|
||||||
|
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||||
this.blocksDAO = blocksDAO;
|
this.blocksDAO = blocksDAO;
|
||||||
this.stateDAO = stateDAO;
|
this.stateDAO = stateDAO;
|
||||||
this.userParamsDAO = userParamsDAO;
|
this.userParamsDAO = userParamsDAO;
|
||||||
this.channelNameStateDAO = channelNameStateDAO;
|
this.channelNameStateDAO = channelNameStateDAO;
|
||||||
|
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendBlockAndState(String blockchainName,
|
public void appendBlockAndState(String blockchainName,
|
||||||
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
|
|||||||
BlockEntry be,
|
BlockEntry be,
|
||||||
UserParamEntry userParamEntry,
|
UserParamEntry userParamEntry,
|
||||||
ChannelNameStateEntry channelNameStateEntry,
|
ChannelNameStateEntry channelNameStateEntry,
|
||||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||||
|
UserNotificationEntry notificationEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
byte[] blockBytes = block.toBytes();
|
byte[] blockBytes = block.toBytes();
|
||||||
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
|
|||||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
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();
|
c.commit();
|
||||||
committed = true;
|
committed = true;
|
||||||
} catch (Exception e) {
|
} 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 {
|
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
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.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
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);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
if (lastPost != null) {
|
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.handlers.connections.entyties.Net_ListContacts_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.MsgSubType;
|
import shine.db.dao.DmDialogStateDAO;
|
||||||
import shine.db.dao.ConnectionsStateDAO;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class Net_ListContacts_Handler implements JsonMessageHandler {
|
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()) {
|
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();
|
Net_ListContacts_Response resp = new Net_ListContacts_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setLogin(ctx.getLogin());
|
resp.setLogin(ctx.getLogin());
|
||||||
resp.setContacts(contacts);
|
resp.setDialogs(toDialogItems(dialogs));
|
||||||
return resp;
|
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 {
|
public class Net_ListContacts_Response extends Net_Response {
|
||||||
private String login;
|
private String login;
|
||||||
private List<String> contacts = new ArrayList<>();
|
private List<DialogItem> dialogs = new ArrayList<>();
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
public List<String> getContacts() { return contacts; }
|
public List<DialogItem> getDialogs() { return dialogs; }
|
||||||
public void setContacts(List<String> contacts) { this.contacts = contacts; }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.BlockchainResyncRecoveryOnStartup;
|
||||||
import server.sync.PeriodicBlockchainSyncService;
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
import server.sync.PeriodicDmSyncService;
|
import server.sync.PeriodicDmSyncService;
|
||||||
|
import server.sync.PeriodicUserSettingsSyncService;
|
||||||
import server.sync.SolanaUsersSyncStartupService;
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
@@ -104,6 +105,7 @@ public final class WsServer {
|
|||||||
server.start();
|
server.start();
|
||||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||||
PeriodicDmSyncService.startOrLog();
|
PeriodicDmSyncService.startOrLog();
|
||||||
|
PeriodicUserSettingsSyncService.startOrLog();
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
// 3) FRIEND взаимно (на HEADER)
|
// 3) FRIEND взаимно (на HEADER)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
bch2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: FRIEND", t);
|
"U1 -> U2: FRIEND", t);
|
||||||
|
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
bch1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: FRIEND", t);
|
"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
|
client.version=1.8.0
|
||||||
server.version=1.6.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}')"
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||||
|
|
||||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
|
>
|
||||||
|
> `unreadCount` для канала считается по `user_settings`:
|
||||||
|
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,14 @@
|
|||||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||||
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
|
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
|
||||||
| `ListUserParams` | `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` | входящие/исходящие друзья |
|
| `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья |
|
||||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||||
|
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||||
@@ -63,6 +67,8 @@
|
|||||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||||
| `DeleteConversation` | `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 по курсору |
|
| `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` | постраничная загрузка истории личного диалога |
|
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
@@ -71,7 +77,6 @@
|
|||||||
## Важные замечания
|
## Важные замечания
|
||||||
|
|
||||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||||
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
|
|
||||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||||
|
|||||||
@@ -66,11 +66,44 @@
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"login": "Alice",
|
"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`
|
## 3. `GetUserConnectionsGraph`
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
|
||||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||||
|
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
|
||||||
|
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
|
||||||
|
|
||||||
## 1. `UpsertPushToken`
|
## 1. `UpsertPushToken`
|
||||||
|
|
||||||
@@ -148,6 +149,12 @@
|
|||||||
|
|
||||||
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||||
|
|
||||||
|
### Примечание
|
||||||
|
|
||||||
|
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
|
||||||
|
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
|
||||||
|
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
|
||||||
|
|
||||||
## 5. `DeleteMessage`
|
## 5. `DeleteMessage`
|
||||||
|
|
||||||
Принимает один signed DM-блок `type=5` или `type=6`.
|
Принимает один signed DM-блок `type=5` или `type=6`.
|
||||||
@@ -360,7 +367,7 @@
|
|||||||
|
|
||||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||||
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
||||||
- сервер не расшифровывает DM и не использует ciphertext как preview текста
|
- сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
|
||||||
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
|
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
|
||||||
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
|
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
|
||||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
- 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` —
|
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||||
|
|
||||||
|
Важно: в текущей архитектуре у пользователя одновременно может быть не более
|
||||||
|
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
|
||||||
|
`synced` и пользовательских курсоров.
|
||||||
|
|
||||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||||
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
||||||
@@ -39,6 +43,13 @@
|
|||||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||||
- Дедупликация по глобальному номеру блока и хэшу.
|
- Дедупликация по глобальному номеру блока и хэшу.
|
||||||
|
|
||||||
|
### 3.3 Пользовательские настройки
|
||||||
|
|
||||||
|
- Отдельная таблица `user_settings`.
|
||||||
|
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
|
||||||
|
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
|
||||||
|
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
|
||||||
|
|
||||||
## 4. Текущая реализованная схема
|
## 4. Текущая реализованная схема
|
||||||
|
|
||||||
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
|
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
|
||||||
|
|||||||
@@ -334,7 +334,24 @@
|
|||||||
|
|
||||||
### 8.2. Новые методы, которые нужны
|
### 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. Правила валидации и применения
|
## 9. Правила валидации и применения
|
||||||
|
|
||||||
@@ -653,7 +670,6 @@ UI-следствие для клиента:
|
|||||||
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
||||||
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
||||||
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
||||||
- legacy `SendDirectMessage` должен быть отключён;
|
|
||||||
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
||||||
|
|
||||||
## 14. Что в v1 пока не входит
|
## 14. Что в v1 пока не входит
|
||||||
|
|||||||
@@ -252,6 +252,15 @@ ReadReceiptBody_v1_0
|
|||||||
|
|
||||||
Различается только `messageType` и формат `body`.
|
Различается только `messageType` и формат `body`.
|
||||||
|
|
||||||
|
### Серверное примечание
|
||||||
|
|
||||||
|
Внешний байтовый формат `type=3/4` не меняется, но сервер использует такие контейнеры как вход для обновления `dm_dialog_state`:
|
||||||
|
|
||||||
|
- `read receipt` обновляет серверный watermark диалога;
|
||||||
|
- `unreadCount` пересчитывается на сервере, а не только на клиенте;
|
||||||
|
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||||
|
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||||
|
|
||||||
## 9. Контент типов `5/6`
|
## 9. Контент типов `5/6`
|
||||||
|
|
||||||
Типы:
|
Типы:
|
||||||
|
|||||||
+311
-6
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
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="/" />
|
<base href="/" />
|
||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||||
<title>СИЯНИЕ</title>
|
<title>СИЯНИЕ</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260806223040';
|
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
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) => {
|
cssFiles.forEach((file) => {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachBootErrorOverlay() {
|
(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 {
|
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');
|
let el = document.getElementById('boot-error-overlay');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('pre');
|
el = document.createElement('pre');
|
||||||
@@ -72,17 +348,46 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
|
|||||||
el.style.lineHeight = '1.4';
|
el.style.lineHeight = '1.4';
|
||||||
el.style.zIndex = '999999';
|
el.style.zIndex = '999999';
|
||||||
el.style.whiteSpace = 'pre-wrap';
|
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);
|
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.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
|
||||||
|
el.setAttribute('aria-label', `${title}. Нажмите, чтобы открыть меню действий.`);
|
||||||
|
el.title = 'Нажмите, чтобы открыть меню действий';
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
window.addEventListener('error', (e) => {
|
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) => {
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
const reason = e?.reason;
|
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>
|
</script>
|
||||||
|
|||||||
+144
-7
@@ -5,7 +5,13 @@ import {
|
|||||||
syncTrackedRouteHistory,
|
syncTrackedRouteHistory,
|
||||||
} from './router.js';
|
} from './router.js';
|
||||||
import { renderToolbar } from './components/toolbar.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 { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||||
import { initPwaPush } from './services/pwa-push-service.js';
|
import { initPwaPush } from './services/pwa-push-service.js';
|
||||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||||
@@ -80,17 +86,17 @@ import * as appLogView from './pages/app-log-view.js';
|
|||||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-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 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 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 channelView from './pages/channel-view.js';
|
||||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||||
import * as networkView from './pages/network-view.js';
|
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||||
import * as notificationsView from './pages/notifications-view.js';
|
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||||
|
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||||
@@ -190,6 +196,15 @@ let initialConnectionCompleted = false;
|
|||||||
let orientationLockInFlight = false;
|
let orientationLockInFlight = false;
|
||||||
let currentChromeCleanup = null;
|
let currentChromeCleanup = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
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([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
@@ -208,6 +223,10 @@ setClientErrorSentNotifier((payload) => {
|
|||||||
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
|
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
|
||||||
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
|
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();
|
initPwaInstallPromptHandling();
|
||||||
initCallUiOverlay();
|
initCallUiOverlay();
|
||||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||||
@@ -221,6 +240,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
|
|||||||
setShellMetricVar('--keyboard-offset', valuePx);
|
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) {
|
function attachSlotHeightObserver(slotEl, cssVarName) {
|
||||||
if (!slotEl || typeof ResizeObserver !== 'function') return null;
|
if (!slotEl || typeof ResizeObserver !== 'function') return null;
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
@@ -237,6 +312,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
|
|||||||
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
|
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
|
||||||
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-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) {
|
function clearSlot(slotEl, cssVarName) {
|
||||||
if (!slotEl) return;
|
if (!slotEl) return;
|
||||||
slotEl.innerHTML = '';
|
slotEl.innerHTML = '';
|
||||||
@@ -1023,6 +1147,14 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
refreshConnectionUi();
|
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() {
|
function renderApp() {
|
||||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
@@ -1059,7 +1191,12 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
screenEl.append(screen);
|
screenEl.append(screen);
|
||||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||||
|
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
||||||
|
currentCleanup = () => {
|
||||||
|
pageCleanup?.();
|
||||||
|
scrollToBottomControl?.cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||||
|
|||||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
|||||||
return (cleanLogin[0] || '?').toUpperCase();
|
return (cleanLogin[0] || '?').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderUserAvatar({
|
export function renderAvatar({
|
||||||
login,
|
initials = '?',
|
||||||
firstName = '',
|
|
||||||
lastName = '',
|
|
||||||
avatar = null,
|
avatar = null,
|
||||||
size = 'large',
|
size = 'large',
|
||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
|
alt = 'Аватар',
|
||||||
|
glow = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
const classes = ['avatar', 'avatar-image'];
|
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||||
const sizeClass = pickSizeClass(size);
|
const sizeClass = pickSizeClass(size);
|
||||||
if (sizeClass) classes.push(sizeClass);
|
if (sizeClass) classes.add(sizeClass);
|
||||||
const extraClass = String(className || '').trim();
|
const extraClass = String(className || '').trim();
|
||||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||||
wrap.className = classes.join(' ');
|
if (glow) classes.add('avatar-glow');
|
||||||
|
wrap.className = Array.from(classes).join(' ');
|
||||||
if (title) wrap.title = String(title);
|
if (title) wrap.title = String(title);
|
||||||
|
|
||||||
const fallback = document.createElement('span');
|
const fallback = document.createElement('span');
|
||||||
fallback.className = 'avatar-fallback';
|
fallback.className = 'avatar-fallback';
|
||||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||||
wrap.append(fallback);
|
wrap.append(fallback);
|
||||||
|
|
||||||
const txId = String(avatar?.ar || '').trim();
|
const txId = String(avatar?.ar || '').trim();
|
||||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
|||||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = 'Аватар';
|
img.className = 'avatar-photo';
|
||||||
|
img.alt = String(alt || 'Аватар');
|
||||||
img.loading = 'lazy';
|
img.loading = 'lazy';
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
wrap.append(img);
|
wrap.append(img);
|
||||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
|||||||
|
|
||||||
return wrap;
|
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');
|
const wrap = document.createElement('header');
|
||||||
wrap.className = 'page-header';
|
wrap.className = 'page-header app-topbar-shell';
|
||||||
|
|
||||||
const left = document.createElement('div');
|
const left = document.createElement('div');
|
||||||
left.className = 'header-left';
|
left.className = 'header-left';
|
||||||
if (leftAction) {
|
if (leftAction) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'icon-btn';
|
const rawLabel = String(leftAction.label || '').trim();
|
||||||
btn.textContent = leftAction.label;
|
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);
|
btn.addEventListener('click', leftAction.onClick);
|
||||||
left.append(btn);
|
left.append(btn);
|
||||||
}
|
}
|
||||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
|||||||
left.append(label);
|
left.append(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
const h1 = document.createElement('h1');
|
const center = document.createElement('div');
|
||||||
h1.className = 'page-title';
|
center.className = 'header-center';
|
||||||
h1.textContent = title;
|
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');
|
const right = document.createElement('div');
|
||||||
right.className = 'header-actions';
|
right.className = 'header-actions';
|
||||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
|||||||
right.append(btn);
|
right.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.append(left, h1, right);
|
wrap.append(left, center, right);
|
||||||
return wrap;
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
openArweaveAttachmentManager,
|
openArweaveAttachmentManager,
|
||||||
markArweaveAttachmentPlaced,
|
markArweaveAttachmentPlaced,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} 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';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
|||||||
function renderAvatarPreview(slot, avatar, title) {
|
function renderAvatarPreview(slot, avatar, title) {
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
const wrap = document.createElement('div');
|
const label = String(title || 'К').trim() || 'К';
|
||||||
wrap.className = 'channel-profile-avatar';
|
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');
|
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);
|
slot.append(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
readArweaveAttachmentHistory,
|
readArweaveAttachmentHistory,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { formatBytes } from '../services/attachment-format.js';
|
import { formatBytes } from '../services/attachment-format.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { state } from '../state.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>
|
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
<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>
|
<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="upload-menu">Добавить файл</button>
|
||||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||||
|
|
||||||
const statusLine = document.createElement('p');
|
const statusLine = document.createElement('p');
|
||||||
statusLine.className = 'meta-muted inline-error';
|
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 { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||||
import { captureClientError } from '../services/client-error-reporter.js';
|
import { captureClientError } from '../services/client-error-reporter.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.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');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
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({
|
const header = renderHeader({
|
||||||
title: '',
|
centerNode: threadHeaderButton,
|
||||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
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');
|
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);
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const current = document.querySelector('section.channels-screen--thread');
|
const current = document.querySelector('section.channels-screen--thread');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
|
current.cleanup?.();
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||||
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
invalid.className = 'card meta-muted';
|
invalid.className = 'card meta-muted';
|
||||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||||
screen.append(invalid);
|
screen.append(invalid);
|
||||||
|
screen.cleanup = () => {
|
||||||
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
getMessageReactionState,
|
getMessageReactionState,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} 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 { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
composeMessageWithAttachments,
|
composeMessageWithAttachments,
|
||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
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) {
|
function firstNonEmptyText(...candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate !== 'string') continue;
|
if (typeof candidate !== 'string') continue;
|
||||||
@@ -381,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChannelAvatarElement(channel, size = 72) {
|
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();
|
const txId = String(channel?.avaAr || '').trim();
|
||||||
if (txId) {
|
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||||
const img = document.createElement('img');
|
const wrap = renderAvatar({
|
||||||
img.alt = 'Аватар канала';
|
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
avatar: txId ? { ar: txId } : null,
|
||||||
wrap.append(img);
|
size: 'small',
|
||||||
} else {
|
className: 'channel-profile-avatar',
|
||||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
title,
|
||||||
}
|
alt: 'Аватар канала',
|
||||||
|
});
|
||||||
|
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,6 +1444,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
async function loadFromApi(route, channelId) {
|
async function loadFromApi(route, channelId) {
|
||||||
const currentSessionLogin = String(state.session.login || '').trim();
|
const currentSessionLogin = String(state.session.login || '').trim();
|
||||||
const isAuthorized = !!currentSessionLogin;
|
const isAuthorized = !!currentSessionLogin;
|
||||||
|
let unreadCount = 0;
|
||||||
|
let messagesCount = 0;
|
||||||
let cachedFeed = null;
|
let cachedFeed = null;
|
||||||
const ensureFeed = async () => {
|
const ensureFeed = async () => {
|
||||||
if (cachedFeed) return cachedFeed;
|
if (cachedFeed) return cachedFeed;
|
||||||
@@ -1309,6 +1506,9 @@ async function loadFromApi(route, channelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||||
|
let unreadCount = 0;
|
||||||
|
let messagesCount = 0;
|
||||||
|
|
||||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||||
@@ -1358,6 +1558,8 @@ async function loadFromApi(route, channelId) {
|
|||||||
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
||||||
throw new Error('Канал не найден.');
|
throw new Error('Канал не найден.');
|
||||||
}
|
}
|
||||||
|
unreadCount = Number(channel?.unreadCount || 0);
|
||||||
|
messagesCount = Number(channel?.messagesCount || 0);
|
||||||
selector = {
|
selector = {
|
||||||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||||||
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
||||||
@@ -1374,6 +1576,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
let reverseChannelMissingWarning = '';
|
let reverseChannelMissingWarning = '';
|
||||||
let mergedMessages = [...messages];
|
let mergedMessages = [...messages];
|
||||||
|
if (!messagesCount) messagesCount = mergedMessages.length;
|
||||||
|
|
||||||
const currentLogin = currentSessionLogin;
|
const currentLogin = currentSessionLogin;
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
@@ -1433,6 +1636,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
|
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
|
||||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: String(payload.channel?.channelDescription || '').trim(),
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
@@ -1445,6 +1649,8 @@ async function loadFromApi(route, channelId) {
|
|||||||
posts,
|
posts,
|
||||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||||
reverseChannelMissingWarning,
|
reverseChannelMissingWarning,
|
||||||
|
unreadCount,
|
||||||
|
messagesCount,
|
||||||
isOwnChannel,
|
isOwnChannel,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
selector,
|
selector,
|
||||||
@@ -1733,6 +1939,9 @@ function renderPostCard(post, {
|
|||||||
if (refKey) {
|
if (refKey) {
|
||||||
card.dataset.messageKey = 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');
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
if (!post.messageRef || !selector) return card;
|
if (!post.messageRef || !selector) return card;
|
||||||
@@ -1898,6 +2107,10 @@ function renderPostCard(post, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
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) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
reverseWarning.className = 'channel-head-meta';
|
reverseWarning.className = 'channel-head-meta';
|
||||||
@@ -1923,6 +2136,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const postsByKey = new Map();
|
const postsByKey = new Map();
|
||||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||||
|
let unreadLineInserted = unreadCount === 0;
|
||||||
const feedItems = [
|
const feedItems = [
|
||||||
...metaEvents.map((event) => ({
|
...metaEvents.map((event) => ({
|
||||||
type: 'meta',
|
type: 'meta',
|
||||||
@@ -1944,6 +2158,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
|
|
||||||
if (feedItems.length) {
|
if (feedItems.length) {
|
||||||
feedItems.forEach((item) => {
|
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') {
|
if (item.type === 'meta') {
|
||||||
feed.append(renderChannelMetaEventCard(item.event));
|
feed.append(renderChannelMetaEventCard(item.event));
|
||||||
return;
|
return;
|
||||||
@@ -1995,10 +2216,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
screen.append(feed, backButton);
|
screen.append(feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
|
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||||
return () => {
|
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||||
// noop
|
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) {
|
function renderSkeleton(screen) {
|
||||||
@@ -2033,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
|
|||||||
statusBox.style.display = '';
|
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({
|
const header = renderHeader({
|
||||||
title: '',
|
centerNode: channelHeaderButton,
|
||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-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');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
if (channelHeaderButton) {
|
|
||||||
channelHeaderButton.disabled = true;
|
|
||||||
}
|
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2056,6 +2293,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const current = document.querySelector('section.channels-screen--channel');
|
const current = document.querySelector('section.channels-screen--channel');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
|
current.cleanup?.();
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
};
|
};
|
||||||
let activeSelector = null;
|
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 { authService, setChannelsFeed, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
|||||||
return first.toUpperCase();
|
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() {
|
function allFeedSummaries() {
|
||||||
const feed = state.channelsFeed || {};
|
const feed = state.channelsFeed || {};
|
||||||
return [
|
return [
|
||||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'channel-row';
|
row.className = 'channel-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
|
||||||
<div class="channel-row-main">
|
<div class="channel-row-main">
|
||||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
<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>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
row.prepend(createChannelAvatar(channel));
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
const route = channel.route || makeShineChannelRoute({
|
const route = channel.route || makeShineChannelRoute({
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
ownerLogin: String(channel.ownerName || 'channel'),
|
||||||
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
|
|||||||
listState,
|
listState,
|
||||||
anchorEl,
|
anchorEl,
|
||||||
navigate,
|
navigate,
|
||||||
onSubscribeChannel,
|
|
||||||
onFindChannel,
|
onFindChannel,
|
||||||
}) {
|
}) {
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
|
|||||||
let left = rect.right - menuWidth;
|
let left = rect.right - menuWidth;
|
||||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||||
|
|
||||||
const estimatedHeight = 320;
|
const estimatedHeight = 250;
|
||||||
let top = rect.bottom + 8;
|
let top = rect.bottom + 8;
|
||||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||||
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
|
|||||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||||
|
|
||||||
const items = [
|
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_ALL)) },
|
||||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
|
||||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -1206,20 +1213,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||||
row.classList.toggle('is-counters-visible', countersVisible);
|
row.classList.toggle('is-counters-visible', countersVisible);
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = createChannelAvatar(channel);
|
||||||
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 main = renderChannelMain(channel);
|
const main = renderChannelMain(channel);
|
||||||
|
|
||||||
const isGuest = !state.session.isAuthorized;
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
@@ -1230,37 +1227,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
|
count.hidden = unreadCount <= 0;
|
||||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
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);
|
controls.append(time, count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
@@ -1373,43 +1343,18 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
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');
|
const topTitle = document.createElement('strong');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.className = 'channels-top-title';
|
||||||
|
|
||||||
const topBarRight = document.createElement('div');
|
const topBarRight = document.createElement('div');
|
||||||
topBarRight.className = 'channels-top-right';
|
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');
|
const topMenuBtn = document.createElement('button');
|
||||||
topMenuBtn.type = 'button';
|
topMenuBtn.type = 'button';
|
||||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||||
topMenuBtn.title = 'Ещё действия';
|
topMenuBtn.title = 'Ещё действия';
|
||||||
topMenuBtn.textContent = '⋮';
|
topMenuBtn.append(createOverflowDots());
|
||||||
topMenuBtn.addEventListener('click', (event) => {
|
topMenuBtn.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(topMenuBtn);
|
animatePress(topMenuBtn);
|
||||||
@@ -1418,18 +1363,11 @@ export function render({ navigate, route, chrome }) {
|
|||||||
anchorEl: topMenuBtn,
|
anchorEl: topMenuBtn,
|
||||||
navigate,
|
navigate,
|
||||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||||
onSubscribeChannel: () => openSimpleSubscribeModal({
|
|
||||||
kind: 'channel',
|
|
||||||
kindLabel: 'Добавить канал',
|
|
||||||
submitLabel: 'Добавить',
|
|
||||||
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
topBarLeft.append(backBtn, topTitle);
|
topBarRight.append(topMenuBtn);
|
||||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
bottomCta.type = 'button';
|
bottomCta.type = 'button';
|
||||||
@@ -1450,10 +1388,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
findChannelBtn.style.display = '';
|
|
||||||
createInMyBtn.style.display = '';
|
|
||||||
topMenuBtn.style.display = '';
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
updateBottomCta({ button: bottomCta });
|
||||||
};
|
};
|
||||||
|
|||||||
+143
-61
@@ -1,4 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
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 { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -27,10 +29,55 @@ import {
|
|||||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.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: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
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) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@@ -572,12 +619,27 @@ function scrollToUnreadSeparator(list) {
|
|||||||
return true;
|
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 = '';
|
list.innerHTML = '';
|
||||||
const messages = getChatMessages(chatId);
|
const messages = getChatMessages(chatId);
|
||||||
let unreadSeparatorInserted = false;
|
let unreadSeparatorInserted = false;
|
||||||
|
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||||
messages.forEach((msg) => {
|
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');
|
const sep = document.createElement('div');
|
||||||
sep.className = 'chat-unread-separator';
|
sep.className = 'chat-unread-separator';
|
||||||
const label = document.createElement('span');
|
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 }) {
|
export function render({ navigate, route, chrome }) {
|
||||||
|
document.body.classList.add('chat-topbar-overlay');
|
||||||
const routeChatId = route.params.chatId || 'u1';
|
const routeChatId = route.params.chatId || 'u1';
|
||||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
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 isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||||
|
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||||
let historyHasMore = true;
|
let historyHasMore = true;
|
||||||
let historyLoading = false;
|
let historyLoading = false;
|
||||||
let historyNextBeforeTimeMs = 0;
|
let historyNextBeforeTimeMs = 0;
|
||||||
let historyNextBeforeMessageKey = '';
|
let historyNextBeforeMessageKey = '';
|
||||||
let historyBootstrapped = false;
|
let historyBootstrapped = false;
|
||||||
let boundScrollContainer = null;
|
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) => {
|
const handleReadAloud = async (msg) => {
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
@@ -819,13 +918,13 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const handleStartCall = async (mode = 'audio') => {
|
const handleStartCall = async (mode = 'audio') => {
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId, { mode });
|
await startOutgoingCall(chatId, { mode });
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||||
from: 'out',
|
from: 'out',
|
||||||
kind: 'call-tech',
|
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,
|
unread: false,
|
||||||
rawBlobB64: String(result?.localBlobB64 || ''),
|
rawBlobB64: String(result?.localBlobB64 || ''),
|
||||||
});
|
});
|
||||||
renderLog(log, chatId, {
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
onOpenActions: handleOpenActions,
|
|
||||||
markAsRead: false,
|
|
||||||
scrollMode: 'latest',
|
|
||||||
});
|
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||||
|
|
||||||
const historyLoader = document.createElement('div');
|
const historyLoader = document.createElement('div');
|
||||||
historyLoader.className = 'dm-history-loader';
|
historyLoader.className = 'dm-history-loader';
|
||||||
historyLoader.hidden = true;
|
historyLoader.hidden = true;
|
||||||
@@ -872,9 +966,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const log = document.createElement('div');
|
const log = document.createElement('div');
|
||||||
log.className = 'messages-log dm-messages-log';
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
chrome?.setTopbar(
|
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||||
renderHeader({
|
const chatHeader = renderHeader({
|
||||||
title: `Чат с ${contact.name}`,
|
centerNode: chatHeaderParts.centerNode,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{
|
{
|
||||||
@@ -885,7 +979,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
onClick: () => handleStartCall('audio'),
|
onClick: () => handleStartCall('audio'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '⋮',
|
iconNode: createOverflowDots(),
|
||||||
title: 'Действия чата',
|
title: 'Действия чата',
|
||||||
ariaLabel: 'Открыть меню действий чата',
|
ariaLabel: 'Открыть меню действий чата',
|
||||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
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) {
|
if (!isKnownContact) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -1010,29 +1105,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
let inputFocused = false;
|
let inputFocused = false;
|
||||||
let emojiPickerOpen = false;
|
let emojiPickerOpen = false;
|
||||||
let emojiSelection = null;
|
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) => {
|
const setHistoryLoadingState = (isLoading) => {
|
||||||
historyLoader.hidden = !isLoading;
|
historyLoader.hidden = !isLoading;
|
||||||
@@ -1235,20 +1307,21 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||||
cancelEditMode({ restoreDraft: true });
|
cancelEditMode({ restoreDraft: true });
|
||||||
}
|
}
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendTextMessage = async (rawText) => {
|
const sendTextMessage = async (rawText) => {
|
||||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||||
const text = safeText.trim();
|
const text = safeText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
hideUnreadSeparator({ rerender: false });
|
||||||
const editing = activeEdit;
|
const editing = activeEdit;
|
||||||
const replying = !editing ? activeReply : null;
|
const replying = !editing ? activeReply : null;
|
||||||
const finalText = editing
|
const finalText = editing
|
||||||
? `${String(editing?.prefixText || '')}${text}`
|
? `${String(editing?.prefixText || '')}${text}`
|
||||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1288,7 +1361,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
cancelReplyMode({ restoreDraft: false });
|
cancelReplyMode({ restoreDraft: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||||
if (localRevisionApplied) {
|
if (localRevisionApplied) {
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
}
|
}
|
||||||
@@ -1331,7 +1404,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
error: e?.message || 'unknown',
|
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);
|
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||||
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
|
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
|
||||||
historyBootstrapped = true;
|
historyBootstrapped = true;
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
|
||||||
if (preserveScroll) {
|
if (preserveScroll) {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
|
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
|
||||||
@@ -1445,12 +1518,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
input?.addEventListener('focus', () => {
|
input?.addEventListener('focus', () => {
|
||||||
rememberEmojiSelection();
|
rememberEmojiSelection();
|
||||||
inputFocused = true;
|
inputFocused = true;
|
||||||
syncKeyboardUi();
|
window.requestAnimationFrame(() => {
|
||||||
scrollToLatestMessage(log);
|
if (inputFocused) scrollToLatestMessage(log);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
input?.addEventListener('blur', () => {
|
input?.addEventListener('blur', () => {
|
||||||
inputFocused = false;
|
inputFocused = false;
|
||||||
setChatKeyboardOpen(false);
|
|
||||||
});
|
});
|
||||||
emojiToggle?.setAttribute('aria-expanded', 'false');
|
emojiToggle?.setAttribute('aria-expanded', 'false');
|
||||||
emojiToggle?.addEventListener('pointerdown', (event) => {
|
emojiToggle?.addEventListener('pointerdown', (event) => {
|
||||||
@@ -1493,25 +1566,34 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const handleIncomingChatRefresh = async (event) => {
|
const handleIncomingChatRefresh = async (event) => {
|
||||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
|
if (Number(event?.detail?.messageType || 0) === 1) {
|
||||||
|
if (!unreadSeparatorVisible) {
|
||||||
|
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
||||||
|
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
||||||
|
}
|
||||||
|
scheduleUnreadSeparatorAutoHide();
|
||||||
|
}
|
||||||
preserveComposerSelection(input, () => {
|
preserveComposerSelection(input, () => {
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
|
renderChatLog({ scrollMode: 'latest' });
|
||||||
});
|
});
|
||||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
|
||||||
window.addEventListener('resize', syncKeyboardUi);
|
|
||||||
|
|
||||||
chrome?.setComposer(form);
|
|
||||||
wrap.append(historyLoader, log);
|
wrap.append(historyLoader, log);
|
||||||
screen.append(wrap);
|
screen.append(wrap);
|
||||||
|
chrome?.setComposer(form);
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId, {
|
||||||
onOpenActions: handleOpenActions,
|
onOpenActions: handleOpenActions,
|
||||||
markAsRead: false,
|
markAsRead: false,
|
||||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||||
|
showUnreadSeparator: unreadSeparatorVisible,
|
||||||
});
|
});
|
||||||
|
if (unreadSeparatorVisible) {
|
||||||
|
scheduleUnreadSeparatorAutoHide();
|
||||||
|
}
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (markChatRead(chatId) > 0) {
|
if (markChatRead(chatId) > 0) {
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
@@ -1519,18 +1601,18 @@ export function render({ navigate, route, chrome }) {
|
|||||||
}, 220);
|
}, 220);
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
|
boundScrollContainer = wrap;
|
||||||
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
|
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
|
||||||
void loadHistoryPage({ preserveScroll: true });
|
void loadHistoryPage({ preserveScroll: true });
|
||||||
});
|
});
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
setChatKeyboardOpen(false);
|
hideUnreadSeparator({ rerender: false });
|
||||||
setKeyboardInset(0);
|
|
||||||
stopAllTwemojiAnimations();
|
stopAllTwemojiAnimations();
|
||||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
|
||||||
window.removeEventListener('resize', syncKeyboardUi);
|
|
||||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||||
|
clearUnreadSeparatorHideTimer();
|
||||||
|
document.body.classList.remove('chat-topbar-overlay');
|
||||||
|
chrome?.setComposer(null);
|
||||||
};
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { directMessages } from '../mock-data.js';
|
|
||||||
import {
|
import {
|
||||||
getChatMessages,
|
authService,
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
normalizeDmChatId,
|
normalizeDmChatId,
|
||||||
setContacts,
|
setContacts,
|
||||||
state,
|
state,
|
||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
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 dmAvatarSnapshotCache = new Map();
|
||||||
const dmAvatarPendingByLogin = new Map();
|
const dmAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
|
const RELATION_ORDER = new Map([
|
||||||
|
['close_friend', 0],
|
||||||
|
['contact', 1],
|
||||||
|
['none', 2],
|
||||||
|
]);
|
||||||
|
|
||||||
async function loadDmAvatarSnapshot(login) {
|
async function loadDmAvatarSnapshot(login) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (!cleanLogin) return null;
|
if (!cleanLogin) return null;
|
||||||
@@ -37,13 +45,14 @@ async function loadDmAvatarSnapshot(login) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDmAvatar(login) {
|
function createDmAvatar(login, { className = '' } = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
|
className,
|
||||||
});
|
});
|
||||||
if (!cleanLogin) return avatarEl;
|
if (!cleanLogin) return avatarEl;
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
@@ -58,6 +67,7 @@ function createDmAvatar(login) {
|
|||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
|
className,
|
||||||
});
|
});
|
||||||
upgraded.classList.add('avatar');
|
upgraded.classList.add('avatar');
|
||||||
avatarEl.replaceWith(upgraded);
|
avatarEl.replaceWith(upgraded);
|
||||||
@@ -65,10 +75,72 @@ function createDmAvatar(login) {
|
|||||||
return avatarEl;
|
return avatarEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLastMessagePreview(text = '') {
|
function normalizeRelationFlag(value) {
|
||||||
const parsed = parseDmTechBlocks(String(text || ''));
|
const clean = String(value || '').trim().toLowerCase();
|
||||||
const display = String(parsed.displayText || '').trim();
|
if (clean === 'close_friend' || clean === 'contact') return clean;
|
||||||
return display || '';
|
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) {
|
function formatChatRowTime(ts) {
|
||||||
@@ -82,13 +154,18 @@ function formatChatRowTime(ts) {
|
|||||||
}).format(new Date(value));
|
}).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 }) {
|
export function render({ navigate, chrome }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
const login = String(state.session.login || '').trim();
|
|
||||||
|
|
||||||
const head = document.createElement('header');
|
const head = document.createElement('header');
|
||||||
head.className = 'dm-head';
|
head.className = 'dm-head';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
@@ -98,23 +175,113 @@ export function render({ navigate, chrome }) {
|
|||||||
<span class="dm-head-name"></span>
|
<span class="dm-head-name"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="dm-head-title">Контакты</h1>
|
<h1 class="dm-head-title">Чаты</h1>
|
||||||
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
<div class="dm-head-menu-wrap">
|
||||||
|
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||||
|
<div class="dm-head-menu" role="menu" hidden>
|
||||||
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
|
<path d="M16 16l4 4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Поиск пользователей</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
const headName = head.querySelector('.dm-head-name');
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
if (headName) headName.textContent = login;
|
menuButton?.append(createOverflowDots());
|
||||||
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
|
|
||||||
|
|
||||||
const divider = document.createElement('div');
|
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||||
divider.className = 'dm-divider';
|
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');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack dm-list';
|
list.className = 'stack dm-list';
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
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');
|
avatarEl.classList.add('avatar');
|
||||||
const avatarWrap = document.createElement('div');
|
const avatarWrap = document.createElement('div');
|
||||||
avatarWrap.className = 'dm-av dm-av--default';
|
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-main">
|
||||||
<div class="dm-row-titleline dm-row-titlewrap">
|
<div class="dm-row-titleline dm-row-titlewrap">
|
||||||
<strong class="dm-row-title"></strong>
|
<strong class="dm-row-title"></strong>
|
||||||
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
<span class="dm-contact-note">${relationBadge}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="dm-row-last-message"></p>
|
<p class="dm-row-last-message"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-row-meta-col">
|
<div class="dm-row-meta-col">
|
||||||
${item.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">
|
<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>
|
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,11 +305,15 @@ export function render({ navigate, chrome }) {
|
|||||||
const titleEl = row.querySelector('.dm-row-title');
|
const titleEl = row.querySelector('.dm-row-title');
|
||||||
const previewEl = row.querySelector('.dm-row-last-message');
|
const previewEl = row.querySelector('.dm-row-last-message');
|
||||||
const timeEl = row.querySelector('.dm-row-time');
|
const timeEl = row.querySelector('.dm-row-time');
|
||||||
if (titleEl) titleEl.textContent = String(item.name || '');
|
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
|
||||||
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
|
if (previewEl) previewEl.textContent = 'Загрузка…';
|
||||||
if (timeEl) timeEl.textContent = String(item.time || '');
|
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
||||||
row.prepend(avatarWrap);
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,60 +327,64 @@ export function render({ navigate, chrome }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relations = await loadCurrentRelations();
|
const payload = await authService.listContacts();
|
||||||
const contacts = relations.outContacts || [];
|
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
||||||
|
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
||||||
setContacts(contacts);
|
setContacts(contacts);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
const contactRows = contacts.map((login) => {
|
const byPeer = new Map();
|
||||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
dialogs.forEach((dialog) => {
|
||||||
const canonicalLogin = normalizeDmChatId(login);
|
const peerLogin = String(dialog?.peerLogin || '').trim();
|
||||||
const chat = getChatMessages(canonicalLogin);
|
if (!peerLogin) return;
|
||||||
const lastChat = chat[chat.length - 1];
|
const key = peerLogin.toLowerCase();
|
||||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
|
||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
const next = {
|
||||||
return {
|
id: peerLogin,
|
||||||
id: canonicalLogin,
|
peerLogin,
|
||||||
name: preview?.name || login,
|
relationFlag,
|
||||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
||||||
time: formatChatRowTime(lastTimeMs),
|
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
||||||
unread,
|
unreadCount: Number(dialog?.unreadCount || 0),
|
||||||
notInContacts: false,
|
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 || {})
|
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||||
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
const orderA = relationOrder(a.relationFlag);
|
||||||
.filter((id) => (getChatMessages(id) || []).length > 0);
|
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) {
|
if (!rows.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
empty.textContent = 'Пока нет диалогов';
|
||||||
list.append(empty);
|
list.append(empty);
|
||||||
return;
|
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) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
@@ -249,7 +424,16 @@ export function render({ navigate, chrome }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chrome?.setTopbar(head);
|
chrome?.setTopbar(head);
|
||||||
screen.append(divider, list);
|
screen.append(list);
|
||||||
loadList();
|
loadList();
|
||||||
|
|
||||||
|
screen.cleanup = () => {
|
||||||
|
closeHeadMenu();
|
||||||
|
document.removeEventListener('click', onOutsideClick);
|
||||||
|
document.removeEventListener('keydown', onMenuKeydown);
|
||||||
|
window.removeEventListener('resize', onMenuViewportChange);
|
||||||
|
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||||
|
};
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
let persistedCenterLogin = '';
|
let persistedCenterLogin = '';
|
||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route, chrome } = {}) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
|
|||||||
else window.history.replaceState({}, '', nextPath);
|
else window.history.replaceState({}, '', nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackButtonState(backBtn) {
|
|
||||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
|
||||||
backBtn.disabled = centerHistory.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSearchModal() {
|
function openSearchModal() {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
|
|||||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
|
||||||
persistHistory();
|
persistHistory();
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== loadSeq) return;
|
if (requestId !== loadSeq) return;
|
||||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||||
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
leftAction: {
|
|
||||||
label: '←',
|
|
||||||
onClick: () => {
|
|
||||||
if (!centerHistory.length) return;
|
|
||||||
const prev = centerHistory.pop();
|
|
||||||
if (!prev) {
|
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void load(prev, { pushHistory: false });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Найти', onClick: openSearchModal },
|
{ label: 'Найти', onClick: openSearchModal },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||||
setBackButtonState(backBtnEl);
|
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||||
|
header.classList.add('network-topbar');
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
|
|||||||
window.setTimeout(() => openSearchModal(), 0);
|
window.setTimeout(() => openSearchModal(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
|
|
||||||
// Панель фильтров слоёв (оверлей под шапкой)
|
// Панель фильтров слоёв (оверлей под шапкой)
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar';
|
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
|
|||||||
filterBar.append(chip);
|
filterBar.append(chip);
|
||||||
});
|
});
|
||||||
|
|
||||||
header.classList.add('network-header-overlay');
|
chrome?.setTopbar(header);
|
||||||
stage.append(board, header, filterBar);
|
stage.append(board, filterBar);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,394 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
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: 'Уведомления' };
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||||
|
|
||||||
function renderList(container) {
|
function normalizeItem(item) {
|
||||||
const active = state.notificationsTab;
|
return {
|
||||||
container.innerHTML = '';
|
kind: String(item?.kind || ''),
|
||||||
const card = document.createElement('article');
|
createdAtMs: Number(item?.createdAtMs || 0),
|
||||||
card.className = 'card stack';
|
sourceLogin: String(item?.sourceLogin || ''),
|
||||||
|
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
||||||
const title = document.createElement('strong');
|
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
||||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
||||||
|
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
||||||
const description = document.createElement('p');
|
sourceText: String(item?.sourceText || ''),
|
||||||
description.className = 'meta-muted';
|
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
||||||
description.textContent = active === 'events'
|
targetLogin: String(item?.targetLogin || ''),
|
||||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
||||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
||||||
|
targetBlockHash: String(item?.targetBlockHash || ''),
|
||||||
const note = document.createElement('p');
|
profile: null,
|
||||||
note.className = 'meta-muted';
|
engagement: null,
|
||||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
};
|
||||||
|
|
||||||
card.append(title, description, note);
|
|
||||||
container.append(card);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack notifications-screen';
|
screen.className = 'stack notifications-screen';
|
||||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
tabs.innerHTML = `
|
tabs.innerHTML = `
|
||||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
<button
|
||||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</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');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack notifications-list';
|
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', () => {
|
btn.addEventListener('click', () => {
|
||||||
state.notificationsTab = btn.dataset.tab;
|
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
if (state.notificationsTab === nextTab) {
|
||||||
btn.classList.add('active');
|
setActiveNotificationTab(nextTab);
|
||||||
renderList(list);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveNotificationTab(nextTab);
|
||||||
|
void load();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.append(tabs, list);
|
screen.append(tabs, list);
|
||||||
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
|
|||||||
const topbar = document.createElement('header');
|
const topbar = document.createElement('header');
|
||||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||||
topbar.innerHTML = `
|
topbar.innerHTML = `
|
||||||
<div class="header-actions profile-top-actions">
|
<div class="header-left" aria-hidden="true"></div>
|
||||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||||
</button>
|
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||||
<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" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const topActions = topbar.querySelector('.profile-top-actions');
|
|
||||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
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);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
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 { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
checkLoginExistsOnSolana,
|
checkLoginExistsOnSolana,
|
||||||
@@ -426,7 +426,13 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Зарегистрироваться',
|
title: 'Зарегистрироваться',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
form,
|
form,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
resetRegistrationFlow,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -102,7 +103,10 @@ export function render({ navigate }) {
|
|||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
cancelButton.addEventListener('click', () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
});
|
||||||
|
|
||||||
const okButton = document.createElement('button');
|
const okButton = document.createElement('button');
|
||||||
okButton.className = 'primary-btn';
|
okButton.className = 'primary-btn';
|
||||||
@@ -190,7 +194,13 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
card,
|
card,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
resetRegistrationFlow,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
stageClosed = true;
|
stageClosed = true;
|
||||||
stopTimers();
|
stopTimers();
|
||||||
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
loginCompleted = true;
|
loginCompleted = true;
|
||||||
stopAutoLogin();
|
stopAutoLogin();
|
||||||
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ function makeClientPlatform() {
|
|||||||
return 'Web';
|
return 'Web';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeUserSettingPart(value = '') {
|
||||||
|
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||||
|
}
|
||||||
|
|
||||||
function hexToBytes(hex) {
|
function hexToBytes(hex) {
|
||||||
const clean = String(hex || '').trim().toLowerCase();
|
const clean = String(hex || '').trim().toLowerCase();
|
||||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||||
@@ -505,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractContactsFromDialogs(dialogs) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
|
||||||
|
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
|
||||||
|
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
|
||||||
|
const login = String(dialog?.peerLogin || '').trim();
|
||||||
|
if (!login) return;
|
||||||
|
const key = login.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(login);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
||||||
const keyBytes = utf8Bytes(String(key || ''));
|
const keyBytes = utf8Bytes(String(key || ''));
|
||||||
const valueBytes = utf8Bytes(String(value || ''));
|
const valueBytes = utf8Bytes(String(value || ''));
|
||||||
@@ -2848,7 +2868,11 @@ export class AuthService {
|
|||||||
async listContacts() {
|
async listContacts() {
|
||||||
const response = await this.ws.request('ListContacts', {});
|
const response = await this.ws.request('ListContacts', {});
|
||||||
if (response.status !== 200) throw opError('ListContacts', response);
|
if (response.status !== 200) throw opError('ListContacts', response);
|
||||||
return response.payload || {};
|
const payload = response.payload || {};
|
||||||
|
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
|
||||||
|
payload.contacts = extractContactsFromDialogs(payload.dialogs);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2858,6 +2882,14 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNotifications(limit = 50) {
|
||||||
|
const payload = {};
|
||||||
|
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||||
|
const response = await this.ws.request('GetNotifications', payload);
|
||||||
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getUserConnectionsGraph(login) {
|
async getUserConnectionsGraph(login) {
|
||||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||||
@@ -2898,6 +2930,59 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertUserSetting({
|
||||||
|
login,
|
||||||
|
settingType,
|
||||||
|
settingKey,
|
||||||
|
timeMs,
|
||||||
|
valueText = '',
|
||||||
|
valueNum = 0,
|
||||||
|
storagePwd,
|
||||||
|
syncDelivery = false,
|
||||||
|
}) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const cleanSettingKey = String(settingKey || '').trim();
|
||||||
|
const cleanValueText = String(valueText ?? '');
|
||||||
|
const cleanTimeMs = Number(timeMs);
|
||||||
|
const cleanSettingType = Number(settingType);
|
||||||
|
const cleanValueNum = Number(valueNum ?? 0);
|
||||||
|
if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey');
|
||||||
|
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs');
|
||||||
|
if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.');
|
||||||
|
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||||
|
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
||||||
|
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
||||||
|
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
||||||
|
|
||||||
|
const preimage = [
|
||||||
|
'SHiNe/UserSettings:',
|
||||||
|
escapeUserSettingPart(cleanLogin),
|
||||||
|
String(cleanSettingType),
|
||||||
|
escapeUserSettingPart(cleanSettingKey),
|
||||||
|
String(Math.trunc(cleanTimeMs)),
|
||||||
|
escapeUserSettingPart(cleanValueText),
|
||||||
|
String(Math.trunc(cleanValueNum)),
|
||||||
|
].join('|');
|
||||||
|
const signature = await signBase64(privateKey, preimage);
|
||||||
|
|
||||||
|
const response = await this.ws.request('UpsertUserSetting', {
|
||||||
|
login: cleanLogin,
|
||||||
|
setting_type: Math.trunc(cleanSettingType),
|
||||||
|
setting_key: cleanSettingKey,
|
||||||
|
time_ms: Math.trunc(cleanTimeMs),
|
||||||
|
value_text: cleanValueText,
|
||||||
|
value_num: Math.trunc(cleanValueNum),
|
||||||
|
client_key: clientKey,
|
||||||
|
signature,
|
||||||
|
sync_delivery: !!syncDelivery,
|
||||||
|
});
|
||||||
|
if (response.status !== 200) throw opError('UpsertUserSetting', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getTestFreeAvatarQuota() {
|
async getTestFreeAvatarQuota() {
|
||||||
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
|
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
|
||||||
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
|
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let transport = null;
|
|||||||
let transportDepth = 0;
|
let transportDepth = 0;
|
||||||
const recentFingerprints = new Map();
|
const recentFingerprints = new Map();
|
||||||
let notifySent = null;
|
let notifySent = null;
|
||||||
|
let lastCapturedPayload = null;
|
||||||
|
|
||||||
function nowTs() {
|
function nowTs() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
@@ -85,6 +86,11 @@ export function setClientErrorSentNotifier(fn) {
|
|||||||
notifySent = typeof fn === 'function' ? fn : null;
|
notifySent = typeof fn === 'function' ? fn : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLastClientErrorPayload() {
|
||||||
|
if (!lastCapturedPayload) return null;
|
||||||
|
return { ...lastCapturedPayload };
|
||||||
|
}
|
||||||
|
|
||||||
export function isClientErrorReportingEnabled() {
|
export function isClientErrorReportingEnabled() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
|
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
|
||||||
@@ -104,6 +110,7 @@ export function setClientErrorReportingEnabled(enabled) {
|
|||||||
export async function captureClientError(details = {}) {
|
export async function captureClientError(details = {}) {
|
||||||
const payload = buildPayload(details);
|
const payload = buildPayload(details);
|
||||||
if (!payload.message) return false;
|
if (!payload.message) return false;
|
||||||
|
lastCapturedPayload = payload;
|
||||||
|
|
||||||
const fingerprint = details.dedupeKey || makeFingerprint(payload);
|
const fingerprint = details.dedupeKey || makeFingerprint(payload);
|
||||||
if (isDuplicate(fingerprint)) return false;
|
if (isDuplicate(fingerprint)) return false;
|
||||||
|
|||||||
@@ -925,6 +925,14 @@ export async function refreshSessions() {
|
|||||||
return state.sessions;
|
return state.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetRegistrationFlow() {
|
||||||
|
const next = createInitialState();
|
||||||
|
state.registrationDraft = next.registrationDraft;
|
||||||
|
state.registrationHelp = next.registrationHelp;
|
||||||
|
state.registrationPayment = next.registrationPayment;
|
||||||
|
state.keyStorage = next.keyStorage;
|
||||||
|
}
|
||||||
|
|
||||||
function resetStateForSignedOut() {
|
function resetStateForSignedOut() {
|
||||||
const next = createInitialState({ withStoredSession: false });
|
const next = createInitialState({ withStoredSession: false });
|
||||||
state.chats = next.chats;
|
state.chats = next.chats;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/*
|
||||||
|
* Единый визуальный язык кнопок основного приложения:
|
||||||
|
* белое содержимое, без рамок и самостоятельной подложки.
|
||||||
|
*
|
||||||
|
* Исключения:
|
||||||
|
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
||||||
|
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root a.primary-btn,
|
||||||
|
:root a.secondary-btn,
|
||||||
|
:root a.destructive-btn,
|
||||||
|
:root a.ghost-btn,
|
||||||
|
:root a.icon-btn,
|
||||||
|
:root a.text-btn {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
transition:
|
||||||
|
transform 90ms ease,
|
||||||
|
box-shadow 90ms ease,
|
||||||
|
background-color 90ms ease,
|
||||||
|
filter 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root a.primary-btn:hover,
|
||||||
|
:root a.secondary-btn:hover,
|
||||||
|
:root a.destructive-btn:hover,
|
||||||
|
:root a.ghost-btn:hover,
|
||||||
|
:root a.icon-btn:hover,
|
||||||
|
:root a.text-btn:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
||||||
|
* Эффект существует только пока кнопка физически нажата.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root a.primary-btn:active,
|
||||||
|
:root a.secondary-btn:active,
|
||||||
|
:root a.destructive-btn:active,
|
||||||
|
:root a.ghost-btn:active,
|
||||||
|
:root a.icon-btn:active,
|
||||||
|
:root a.text-btn:active {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: rgba(0, 0, 0, 0.12) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
transform: translateY(1px) scale(0.97);
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
||||||
|
:root a.primary-btn[aria-disabled='true'],
|
||||||
|
:root a.secondary-btn[aria-disabled='true'],
|
||||||
|
:root a.destructive-btn[aria-disabled='true'],
|
||||||
|
:root a.ghost-btn[aria-disabled='true'],
|
||||||
|
:root a.icon-btn[aria-disabled='true'],
|
||||||
|
:root a.text-btn[aria-disabled='true'] {
|
||||||
|
color: rgba(255, 255, 255, 0.42) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
||||||
|
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
||||||
|
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
||||||
|
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
||||||
|
*/
|
||||||
|
:root .toolbar-btn {
|
||||||
|
transition:
|
||||||
|
transform 90ms ease,
|
||||||
|
box-shadow 90ms ease,
|
||||||
|
background-color 90ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .toolbar-btn:active {
|
||||||
|
transform: translateY(1px) scale(0.96);
|
||||||
|
background: rgba(0, 0, 0, 0.14) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root a.primary-btn:focus-visible,
|
||||||
|
:root a.secondary-btn:focus-visible,
|
||||||
|
:root a.destructive-btn:focus-visible,
|
||||||
|
:root a.ghost-btn:focus-visible,
|
||||||
|
:root a.icon-btn:focus-visible,
|
||||||
|
:root a.text-btn:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Уведомления: «Ответы / События».
|
||||||
|
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
||||||
|
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
||||||
|
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
||||||
|
*/
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: rgba(0, 0, 0, 0.18) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||||
|
transform: translateY(1px) scale(0.965) !important;
|
||||||
|
filter: brightness(0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
||||||
|
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
||||||
|
*/
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
||||||
|
background: rgba(0, 0, 0, 0.12) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
transform: translateY(1px) scale(0.97) !important;
|
||||||
|
filter: brightness(0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
||||||
|
* Это правило намеренно расположено после глобального белого button-rule. */
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||||
|
color: var(--app-topbar-gold) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
||||||
|
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||||
|
color: #F7FBFF !important;
|
||||||
|
text-shadow:
|
||||||
|
0 0 5px rgba(92, 190, 255, 0.72),
|
||||||
|
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
outline: none !important;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
||||||
|
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn,
|
||||||
|
:root .dm-chat-input button.dm-send-btn,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close,
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn:hover,
|
||||||
|
:root .dm-chat-input button.dm-send-btn:hover,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn:focus,
|
||||||
|
:root .dm-chat-input button.dm-send-btn:focus,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
||||||
|
color: #F7FBFF !important;
|
||||||
|
text-shadow:
|
||||||
|
0 0 5px rgba(92, 190, 255, 0.72),
|
||||||
|
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
||||||
|
}
|
||||||
+1677
-27
File diff suppressed because it is too large
Load Diff
+53
-22
@@ -2,7 +2,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #05070A;
|
background: #05070A;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -22,12 +22,11 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(100vw, 430px);
|
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||||
height: 100vh;
|
height: var(--app-viewport-height, 100vh);
|
||||||
height: 100svh;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: var(--app-viewport-offset-top, 0px);
|
||||||
left: 50%;
|
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
--call-minimized-bar-height: 0px;
|
--call-minimized-bar-height: 0px;
|
||||||
--topbar-height: 0px;
|
--topbar-height: 0px;
|
||||||
@@ -94,10 +93,11 @@ body::before {
|
|||||||
|
|
||||||
.composer-slot {
|
.composer-slot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||||
|
transform: translateX(-50%);
|
||||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||||
padding: 0 12px 8px;
|
padding: 0 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,20 +115,42 @@ body::before {
|
|||||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.chat-keyboard-open .screen-content {
|
body.chat-topbar-overlay .topbar-slot {
|
||||||
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)));
|
position: fixed;
|
||||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
top: var(--call-minimized-bar-height, 0px);
|
||||||
}
|
left: 0;
|
||||||
|
right: 0;
|
||||||
body.chat-keyboard-open .composer-slot {
|
width: min(100vw, 430px);
|
||||||
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px));
|
margin: 0 auto;
|
||||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.chat-keyboard-open .toolbar-slot {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: none;
|
transform: none;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-topbar-overlay .screen-content {
|
||||||
|
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
|
||||||
|
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-topbar-overlay .composer-slot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||||
|
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
|
||||||
|
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
|
||||||
|
visualViewport, а область сообщений заканчивается прямо над composer. */
|
||||||
|
.app-shell.keyboard-open .toolbar-slot {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
|
||||||
|
bottom: var(--keyboard-offset, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
|
||||||
|
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-retry-banner {
|
.connection-retry-banner {
|
||||||
@@ -177,3 +199,12 @@ body.chat-keyboard-open .toolbar-slot {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
|
||||||
|
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
.fg-orb-host .fg-pngorb-init {
|
.fg-orb-host .fg-pngorb-init {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||||
.fg-filter-bar {
|
.fg-filter-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
|
|||||||
Reference in New Issue
Block a user