Compare commits

..
Author SHA256 Message Date
AidarKC 7a5ac01d1c UI: render DM inbox from dialogs 2026-08-22 18:47:14 +04:00
AidarKC 5e6d64e965 Split DM dialog migration to v11 2026-08-22 18:38:07 +04:00
AidarKC c425fa41aa UI: keep local shell updates 2026-08-22 18:34:40 +04:00
AidarKC 781157299f Server-side DM dialogs state 2026-08-22 18:31:32 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
AidarKC 0a4c31fb36 Refine unread marker and reset registration flow 2026-08-21 15:04:29 +04:00
AidarKC fef7694b48 Show unread count in channel list 2026-08-21 14:59:03 +04:00
AidarKC 60206e21df Accept user settings cursor writes on fallback 2026-08-21 14:52:45 +04:00
AidarKC b9b77c66ce Restore unread baseline and read tracking 2026-08-21 14:24:46 +04:00
AidarKC 745a0e39d7 Fix unread channel counts and badges 2026-08-21 13:45:49 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
AidarKC 6a5c20a165 Добавить user_settings и синхронизацию настроек 2026-08-19 19:45:09 +04:00
AidarKC fac166f186 Удалить legacy-стек прямых сообщений 2026-08-19 18:11:00 +04:00
71 changed files with 4116 additions and 690 deletions
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -25,6 +25,9 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_6 = 6;
public static final int SCHEMA_VERSION_7 = 7;
public static final int SCHEMA_VERSION_8 = 8;
public static final int SCHEMA_VERSION_9 = 9;
public static final int SCHEMA_VERSION_10 = 10;
public static final int SCHEMA_VERSION_11 = 11;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -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_V7_RESOURCE = "postgres/migration_v7.sql";
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
private DatabaseInitializer() {}
@@ -124,6 +130,18 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_8) {
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
currentVersion = SCHEMA_VERSION_8;
}
if (currentVersion < SCHEMA_VERSION_9) {
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
currentVersion = SCHEMA_VERSION_9;
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
currentVersion = SCHEMA_VERSION_10;
}
if (currentVersion < SCHEMA_VERSION_11) {
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
}
}
}
@@ -1,6 +1,7 @@
package shine.db;
import shine.db.connection.DriverManagerDbProvider;
import shine.db.dao.DmDialogStateDAO;
import utils.config.AppConfig;
import java.sql.Connection;
@@ -34,6 +35,12 @@ public final class PostgresDbController {
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
connection.setAutoCommit(true);
});
try (Connection connection = this.delegate.getConnection()) {
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
} catch (SQLException e) {
throw new RuntimeException("DM dialog state bootstrap failed", e);
}
}
public static PostgresDbController getInstance() {
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
return out;
}
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
String sql = """
SELECT 1
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
AND (
LOWER(cs.to_login) = LOWER(?)
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
)
LIMIT 1
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setInt(2, relType);
ps.setString(3, peerLoginAnyCase);
ps.setString(4, peerLoginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
/**
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
*/
@@ -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) {}
}
@@ -1,47 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDirectMessagesHistoryDAO {
private static volatile SignedDirectMessagesHistoryDAO instance;
private final DbController db = DbController.getInstance();
private SignedDirectMessagesHistoryDAO() {}
public static SignedDirectMessagesHistoryDAO getInstance() {
if (instance == null) {
synchronized (SignedDirectMessagesHistoryDAO.class) {
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
}
}
return instance;
}
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_messages_history (
message_id, from_login, to_login, target_mode, target_session_id,
message_type, time_ms, nonce, raw_packet, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageId());
ps.setString(2, e.getFromLogin());
ps.setString(3, e.getToLogin());
ps.setInt(4, e.getTargetMode());
ps.setString(5, e.getTargetSessionId());
ps.setInt(6, e.getMessageType());
ps.setLong(7, e.getTimeMs());
ps.setLong(8, e.getNonce());
ps.setBytes(9, e.getRawPacket());
ps.setLong(10, e.getCreatedAtMs());
ps.executeUpdate();
}
}
}
}
@@ -1,51 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDmReplayDAO {
private static volatile SignedDmReplayDAO instance;
private final DbController db = DbController.getInstance();
private SignedDmReplayDAO() {}
public static SignedDmReplayDAO getInstance() {
if (instance == null) {
synchronized (SignedDmReplayDAO.class) {
if (instance == null) instance = new SignedDmReplayDAO();
}
}
return instance;
}
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
cleanupExpired(nowMs - 15L * 60L * 1000L);
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_message_replay (
from_login, time_ms, nonce, created_at_ms
) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setLong(2, timeMs);
ps.setLong(3, nonce);
ps.setLong(4, nowMs);
return ps.executeUpdate() > 0;
}
}
}
public void cleanupExpired(long minCreatedAtMs) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, minCreatedAtMs);
ps.executeUpdate();
}
}
}
}
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
if (status.applied()) {
markMessageReadByReceipt(c, e);
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
}
return status;
}
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
if (insertedFirst == 1 && insertedSecond == 1) {
markMessageReadByReceipt(c, first);
markMessageReadByReceipt(c, second);
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
c.commit();
return true;
}
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
markMessageReadByReceipt(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
upsertMessage(c, incoming);
markMessageReadByReceipt(c, incoming);
resetDeliveryRows(c, incoming.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
} catch (Exception ex) {
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -0,0 +1,231 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserSettingEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
/**
* UserSettingsDAO — хранение пользовательских настроек.
*
* Правило:
* - уникальность: login + setting_type + setting_key
* - запись обновляется только если time_ms новее
* - synced=true означает, что значение уже дошло до второго сервера
*/
public final class UserSettingsDAO {
private static volatile UserSettingsDAO instance;
private final DbController db = DbController.getInstance();
private UserSettingsDAO() {}
public static UserSettingsDAO getInstance() {
if (instance == null) {
synchronized (UserSettingsDAO.class) {
if (instance == null) instance = new UserSettingsDAO();
}
}
return instance;
}
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
String sql = """
INSERT INTO user_settings (
login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature, synced
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (login, setting_type, setting_key)
DO UPDATE SET
time_ms = EXCLUDED.time_ms,
value_text = EXCLUDED.value_text,
value_num = EXCLUDED.value_num,
client_key = EXCLUDED.client_key,
signature = EXCLUDED.signature,
synced = EXCLUDED.synced
WHERE user_settings.time_ms < EXCLUDED.time_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getLogin());
ps.setInt(2, e.getSettingType());
ps.setString(3, e.getSettingKey());
ps.setLong(4, e.getTimeMs());
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
ps.setLong(6, e.getValueNum());
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
else ps.setString(7, e.getClientKey());
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
else ps.setString(8, e.getSignature());
ps.setBoolean(9, e.isSynced());
return ps.executeUpdate();
}
}
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
return upsertIfNewer(c, e);
}
}
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, settingType);
ps.setString(3, settingKey);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLoginTypeKey(c, login, settingType, settingKey);
}
}
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public List<UserSettingEntry> listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND (
time_ms > ?
OR (time_ms = ? AND setting_key > ?)
)
ORDER BY time_ms ASC, setting_key ASC
LIMIT ?
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setLong(2, Math.max(0L, afterTimeMs));
ps.setLong(3, Math.max(0L, afterTimeMs));
ps.setString(4, afterSettingKey == null ? "" : afterSettingKey);
ps.setInt(5, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserSettingEntry> listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND synced = FALSE
ORDER BY time_ms ASC, setting_key ASC
LIMIT ?
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException {
String sql = """
UPDATE user_settings
SET synced = TRUE
WHERE LOWER(login) = LOWER(?)
AND setting_type = ?
AND setting_key = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, settingType);
ps.setString(3, settingKey);
return ps.executeUpdate();
}
}
public int markAllUnsynced(Connection c, String login) throws SQLException {
String sql = """
UPDATE user_settings
SET synced = FALSE
WHERE LOWER(login) = LOWER(?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
return ps.executeUpdate();
}
}
public int markAllUnsynced() throws SQLException {
try (Connection c = db.getConnection()) {
return markAllUnsynced(c);
}
}
public int markAllUnsynced(Connection c) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) {
return ps.executeUpdate();
}
}
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
UserSettingEntry e = new UserSettingEntry();
e.setLogin(rs.getString("login"));
e.setSettingType(rs.getInt("setting_type"));
e.setSettingKey(rs.getString("setting_key"));
e.setTimeMs(rs.getLong("time_ms"));
e.setValueText(rs.getString("value_text"));
e.setValueNum(rs.getLong("value_num"));
String clientKey = rs.getString("client_key");
if (rs.wasNull()) clientKey = null;
e.setClientKey(clientKey);
String signature = rs.getString("signature");
if (rs.wasNull()) signature = null;
e.setSignature(signature);
e.setSynced(rs.getBoolean("synced"));
return e;
}
}
@@ -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; }
}
@@ -1,35 +0,0 @@
package shine.db.entities;
public class SignedDirectMessageHistoryEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private int targetMode;
private String targetSessionId;
private int messageType;
private long timeMs;
private long nonce;
private byte[] rawPacket;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getTargetMode() { return targetMode; }
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
public String getTargetSessionId() { return targetSessionId; }
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getNonce() { return nonce; }
public void setNonce(long nonce) { this.nonce = nonce; }
public byte[] getRawPacket() { return rawPacket; }
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,68 @@
package shine.db.entities;
/**
* UserSettingEntry — одна пользовательская настройка.
*
* Таблица: user_settings
* - login TEXT NOT NULL
* - setting_type INTEGER NOT NULL
* - setting_key TEXT NOT NULL
* - time_ms BIGINT NOT NULL
* - value_text TEXT NOT NULL
* - value_num BIGINT NOT NULL
* - client_key TEXT NOT NULL
* - signature TEXT NOT NULL
* - synced BOOLEAN NOT NULL
*/
public class UserSettingEntry {
private String login;
private int settingType;
private String settingKey;
private long timeMs;
private String valueText;
private long valueNum;
private String clientKey;
private String signature;
private boolean synced;
public UserSettingEntry() {}
public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) {
this.login = login;
this.settingType = settingType;
this.settingKey = settingKey;
this.timeMs = timeMs;
this.valueText = valueText;
this.valueNum = valueNum;
this.clientKey = clientKey;
this.signature = signature;
this.synced = synced;
}
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public int getSettingType() { return settingType; }
public void setSettingType(int settingType) { this.settingType = settingType; }
public String getSettingKey() { return settingKey; }
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public String getValueText() { return valueText; }
public void setValueText(String valueText) { this.valueText = valueText; }
public long getValueNum() { return valueNum; }
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public boolean isSynced() { return synced; }
public void setSynced(boolean synced) { this.synced = synced; }
}
@@ -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)
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
schema_version = EXCLUDED.schema_version,
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
ON users_params(login);
CREATE TABLE IF NOT EXISTS user_settings (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
setting_type INTEGER NOT NULL,
setting_key TEXT NOT NULL,
time_ms BIGINT NOT NULL,
value_text TEXT NOT NULL DEFAULT '',
value_num BIGINT NOT NULL DEFAULT 0,
client_key TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',
synced BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (login, setting_type, setting_key)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_login
ON user_settings(login);
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
ON user_settings(login, synced, time_ms);
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_key);
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
cursor_setting_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
ON user_settings_sync_peer_state(owner_login);
CREATE TABLE IF NOT EXISTS ip_geo_cache (
ip TEXT PRIMARY KEY,
geo TEXT,
@@ -713,6 +751,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
ON signed_message_session_delivery(session_id, delivered);
CREATE TABLE IF NOT EXISTS user_notifications_state (
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
source_block_hash BYTEA NOT NULL,
target_login TEXT,
target_bch_name TEXT,
target_block_number INTEGER,
target_block_hash BYTEA,
source_msg_sub_type INTEGER NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
ON user_notifications_state(owner_login, created_at_ms DESC);
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
@@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
// --- NEW: connections friends lists ---
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
@@ -90,11 +96,12 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_MarkAllUserSettingsUnsynced_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UserSettingsSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
@@ -103,11 +110,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
@@ -182,6 +190,11 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
// --- userSettings ---
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
// --- connections ---
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
@@ -204,6 +217,8 @@ public final class JsonHandlerRegistry {
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()),
Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
@@ -264,6 +279,11 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
// --- userSettings ---
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
// --- connections ---
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
@@ -286,6 +306,8 @@ public final class JsonHandlerRegistry {
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class),
Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
@@ -144,6 +144,40 @@ final class ChannelsReadSupport {
}
}
static String userSettingsChannelKey(String ownerBch, String channelName) {
String bch = ownerBch == null ? "" : ownerBch.trim();
String name = channelName == null ? "" : channelName.trim();
return bch + "/" + name;
}
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
String key = userSettingsChannelKey(ownerBch, channelName);
String sql = """
SELECT value_num
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND setting_type = ?
AND setting_key = ?
LIMIT 1
""";
long lastSeen = messagesCount;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setInt(2, 1);
ps.setString(3, key);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong("value_num");
if (!rs.wasNull()) lastSeen = value;
}
}
}
if (lastSeen < 0) lastSeen = 0;
if (lastSeen > messagesCount) return 0;
return Math.max(0, messagesCount - (int) lastSeen);
}
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
@@ -82,7 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(0);
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
@@ -8,10 +8,10 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public class Net_ListContacts_Handler implements JsonMessageHandler {
@@ -23,14 +23,30 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
}
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
List<DmDialogStateDAO.DialogSummary> dialogs = DmDialogStateDAO.getInstance().listInboxDialogs(c, ctx.getLogin());
Net_ListContacts_Response resp = new Net_ListContacts_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin());
resp.setContacts(contacts);
resp.setDialogs(toDialogItems(dialogs));
return resp;
}
}
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
if (dialogs == null) return items;
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
item.setPeerLogin(dialog.peerLogin());
item.setRelationFlag(dialog.relationFlag());
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
item.setUnreadCount(dialog.unreadCount());
item.setHasDialog(dialog.hasDialog());
items.add(item);
}
return items;
}
}
@@ -7,10 +7,32 @@ import java.util.List;
public class Net_ListContacts_Response extends Net_Response {
private String login;
private List<String> contacts = new ArrayList<>();
private List<DialogItem> dialogs = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getContacts() { return contacts; }
public void setContacts(List<String> contacts) { this.contacts = contacts; }
public List<DialogItem> getDialogs() { return dialogs; }
public void setDialogs(List<DialogItem> dialogs) { this.dialogs = dialogs; }
public static class DialogItem {
private String peerLogin;
private String relationFlag;
private String lastMessageBlobB64;
private long lastMessageTimeMs;
private int unreadCount;
private boolean hasDialog;
public String getPeerLogin() { return peerLogin; }
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
public String getRelationFlag() { return relationFlag; }
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
public void setLastMessageTimeMs(long lastMessageTimeMs) { this.lastMessageTimeMs = lastMessageTimeMs; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public boolean isHasDialog() { return hasDialog; }
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
}
}
@@ -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", "Внутренняя ошибка сервера");
}
}
}
@@ -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", "Внутренняя ошибка сервера");
}
}
}
@@ -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("|", "\\|");
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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", "Внутренняя ошибка сервера");
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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) {}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
}
@@ -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.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmSyncService;
import server.sync.PeriodicUserSettingsSyncService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import utils.config.AppConfig;
@@ -104,6 +105,7 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
PeriodicDmSyncService.startOrLog();
PeriodicUserSettingsSyncService.startOrLog();
server.join();
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.7.0
server.version=1.6.0
server.version=1.6.1
+3
View File
@@ -23,6 +23,9 @@
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
---
+5 -1
View File
@@ -51,6 +51,9 @@
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
| `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя |
| `ListUserParams` | `10_User_Params_API.md` | список параметров пользователя |
| `UpsertUserSetting` | `13_User_Settings_API.md` | запись пользовательской настройки |
| `GetUserSetting` | `13_User_Settings_API.md` | чтение одной пользовательской настройки |
| `ListUserSettings` | `13_User_Settings_API.md` | список пользовательских настроек |
| `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья |
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
@@ -63,6 +66,8 @@
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору |
| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных |
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
@@ -71,7 +76,6 @@
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+34 -1
View File
@@ -66,11 +66,44 @@
"ok": true,
"payload": {
"login": "Alice",
"contacts": ["Bob", "Kate"]
"dialogs": [
{
"peerLogin": "Bob",
"relationFlag": "close_friend",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000123,
"unreadCount": 2,
"hasDialog": true
},
{
"peerLogin": "Kate",
"relationFlag": "contact",
"lastMessageBlobB64": "",
"lastMessageTimeMs": 0,
"unreadCount": 0,
"hasDialog": false
},
{
"peerLogin": "Mira",
"relationFlag": "none",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000555,
"unreadCount": 1,
"hasDialog": true
}
]
}
}
```
### Примечание
- `dialogs` это серверный inbox-проекционный список диалогов;
- `relationFlag` возвращается как `close_friend`, `contact` или `none`;
- если один и тот же человек есть и в `contact`, и в `close_friend`, в `dialogs` он приходит как `close_friend`.
- `lastMessageBlobB64` содержит полный signed DM block последнего контентного сообщения в base64;
- для чатов без сообщений поле `lastMessageBlobB64` пустое.
---
## 3. `GetUserConnectionsGraph`
@@ -9,9 +9,10 @@
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
## 1. `UpsertPushToken`
@@ -148,6 +149,12 @@
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
### Примечание
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
## 5. `DeleteMessage`
Принимает один signed DM-блок `type=5` или `type=6`.
@@ -360,7 +367,7 @@
- все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
- сервер не расшифровывает DM и не использует ciphertext как preview текста
- сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
- HTTP endpoints для DM-файлов сейчас отсутствуют
+99
View File
@@ -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 в списке каналов и в канале.
+11
View File
@@ -19,6 +19,10 @@
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
Важно: в текущей архитектуре у пользователя одновременно может быть не более
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
`synced` и пользовательских курсоров.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
@@ -39,6 +43,13 @@
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
### 3.3 Пользовательские настройки
- Отдельная таблица `user_settings`.
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
## 4. Текущая реализованная схема
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
@@ -334,7 +334,24 @@
### 8.2. Новые методы, которые нужны
Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
### 8.3. Серверный слой диалогов
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
- отдельная запись на пару `owner_login` + `peer_login`;
- `relation_flag` со значениями `close_friend`, `contact`, `none`;
- `last_message_blob_b64` как последний контентный signed DM block в base64;
- `last_message_time_ms`;
- `unread_count`;
- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения.
Ключевые правила:
- `close_friend` всегда имеет приоритет над `contact`;
- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад;
- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`;
- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции;
- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
## 9. Правила валидации и применения
@@ -653,7 +670,6 @@ UI-следствие для клиента:
- межсерверная маршрутизация DM должна идти через `access_servers`;
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
- legacy `SendDirectMessage` должен быть отключён;
- логика должна быть безопасна для нескольких серверов у каждой стороны.
## 14. Что в v1 пока не входит
@@ -252,6 +252,15 @@ ReadReceiptBody_v1_0
Различается только `messageType` и формат `body`.
### Серверное примечание
Внешний байтовый формат `type=3/4` не меняется, но сервер использует такие контейнеры как вход для обновления `dm_dialog_state`:
- `read receipt` обновляет серверный watermark диалога;
- `unreadCount` пересчитывается на сервере, а не только на клиенте;
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
## 9. Контент типов `5/6`
Типы:
+310 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
/>
<base href="/" />
<link rel="manifest" href="./manifest.webmanifest" />
@@ -12,7 +12,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260806223040';
window.__SHINE_BUILD_HASH__ = '20260819190000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
</script>
<script>
(function attachBootErrorOverlay() {
const show = (title, text) => {
const stateKey = '__SHINE_BOOT_ERROR_STATE__';
const menuId = 'boot-error-action-sheet';
const escapeText = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const safeString = (value, maxLen = 1000) => {
const text = String(value == null ? '' : value).trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
};
const setState = (next) => {
try {
window[stateKey] = next;
} catch {}
};
const getState = () => {
try {
return window[stateKey] || null;
} catch {
return null;
}
};
const getKnownClientError = () => {
try {
return typeof window.__SHINE_GET_LAST_CLIENT_ERROR__ === 'function'
? window.__SHINE_GET_LAST_CLIENT_ERROR__()
: null;
} catch {
return null;
}
};
const showFeedback = (message, kind = 'success') => {
try {
if (typeof window.__SHINE_SHOW_TOAST__ === 'function') {
window.__SHINE_SHOW_TOAST__(message, kind);
} else {
console.info(message);
}
} catch {}
};
const buildReport = () => {
const state = getState() || {};
const known = getKnownClientError();
const viewport = {
width: Math.round(window.innerWidth || 0),
height: Math.round(window.innerHeight || 0),
dpr: Number(window.devicePixelRatio || 1),
visualWidth: Math.round(window.visualViewport?.width || 0),
visualHeight: Math.round(window.visualViewport?.height || 0),
visualScale: Number(window.visualViewport?.scale || 1),
};
const screenInfo = window.screen ? {
width: Math.round(window.screen.width || 0),
height: Math.round(window.screen.height || 0),
availWidth: Math.round(window.screen.availWidth || 0),
availHeight: Math.round(window.screen.availHeight || 0),
pixelDepth: Number(window.screen.pixelDepth || 0),
} : null;
return {
kind: safeString(state.kind || 'boot_error', 64),
title: safeString(state.title || 'BOOT ERROR', 128),
message: safeString(state.message || '', 500),
stack: safeString(state.stack || '', 8000),
sourceUrl: safeString(state.sourceUrl || '', 240),
lineNumber: Number.isFinite(state.lineNumber) ? state.lineNumber : null,
columnNumber: Number.isFinite(state.columnNumber) ? state.columnNumber : null,
reasonType: safeString(state.reasonType || '', 64),
route: safeString(window.location.hash || window.location.pathname || '', 200),
href: safeString(window.location.href || '', 240),
pageTitle: safeString(document.title || '', 200),
pageVisibility: safeString(document.visibilityState || '', 32),
userAgent: safeString(navigator.userAgent || '', 240),
locale: safeString(navigator.language || '', 32),
clientTs: Number.isFinite(state.clientTs) ? state.clientTs : Date.now(),
viewport,
screenInfo,
lastKnownClientError: known || null,
contextJson: safeString(JSON.stringify({
bootState: state,
currentRoute: window.location.hash || window.location.pathname || '',
hasClientErrorSender: typeof window.__SHINE_SEND_CLIENT_ERROR__ === 'function',
}), 2000),
};
};
const buildSendPayload = (report) => {
const payload = {
kind: safeString(report?.kind || 'boot_error', 64),
message: safeString(report?.message || report?.title || 'Неизвестная ошибка', 500),
stack: safeString(report?.stack || '', 8000),
sourceUrl: safeString(report?.sourceUrl || '', 240),
lineNumber: Number.isFinite(report?.lineNumber) ? report.lineNumber : null,
columnNumber: Number.isFinite(report?.columnNumber) ? report.columnNumber : null,
route: safeString(report?.route || '', 200),
href: safeString(report?.href || '', 240),
userAgent: safeString(report?.userAgent || '', 240),
clientTs: Number.isFinite(report?.clientTs) ? report.clientTs : Date.now(),
requestOp: '',
requestIdRef: '',
contextJson: safeString(JSON.stringify({
...report,
contextJson: undefined,
}), 2000),
};
return payload;
};
const shareErrorText = async (report) => {
const text = formatReportText(report);
if (!text) throw new Error('Текст ошибки пуст');
if (!navigator.share) {
throw new Error('Отправка через системное меню недоступна в этом браузере');
}
try {
await navigator.share({
title: report?.title || 'Описание ошибки',
text,
});
} catch (error) {
if (error?.name === 'AbortError') return false;
throw error;
}
return true;
};
const formatReportText = (report) => {
const lines = [
`Ошибка: ${report.title || report.kind || 'unknown'}`,
`Описание: ${report.message || '—'}`,
`Окно: ${report.pageTitle || '—'}`,
`Маршрут: ${report.route || '—'}`,
`URL: ${report.href || '—'}`,
`Видимость: ${report.pageVisibility || '—'}`,
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
`UA: ${report.userAgent || '—'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
`Источник: ${report.sourceUrl || '—'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
`Тип причины: ${report.reasonType || '—'}`,
];
if (report.stack) {
lines.push('Stack:', report.stack);
}
if (report.lastKnownClientError) {
lines.push('Последняя известная ошибка:', JSON.stringify(report.lastKnownClientError, null, 2));
}
if (report.contextJson) {
lines.push('Контекст:', report.contextJson);
}
return lines.join('\n');
};
const copyText = async (text) => {
const value = String(text || '');
if (!value) return false;
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
};
const removeMenu = () => {
try {
document.getElementById(menuId)?.remove();
} catch {}
};
const openMenu = () => {
try {
removeMenu();
const report = buildReport();
const root = document.getElementById('modal-root') || document.body;
const shell = document.createElement('div');
shell.id = menuId;
shell.className = 'modal-shell boot-error-menu-shell';
shell.innerHTML = `
<div class="modal-backdrop" data-action="close"></div>
<div class="modal-dialog boot-error-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="boot-error-menu-title" tabindex="-1">
<div class="modal-card stack boot-error-menu-card">
<strong class="modal-title" id="boot-error-menu-title">Описание ошибки</strong>
<p class="meta-muted boot-error-menu-message">${escapeText(report.message || report.title || 'Ошибка')}</p>
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
</div>
</div>
`;
root.append(shell);
const dialog = shell.querySelector('.modal-dialog');
const close = () => {
window.removeEventListener('keydown', onKeyDown);
shell.remove();
};
const onKeyDown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
shell.addEventListener('click', (event) => {
if (event.target === shell || event.target?.dataset?.action === 'close') {
close();
}
});
shell.querySelector('[data-action="copy"]')?.addEventListener('click', async () => {
try {
await copyText(formatReportText(report));
showFeedback('Описание ошибки скопировано');
} catch {
showFeedback('Не удалось скопировать описание ошибки', 'error');
}
close();
});
shell.querySelector('[data-action="share"]')?.addEventListener('click', async () => {
try {
const shared = await shareErrorText(report);
if (shared === false) return close();
showFeedback('Текст ошибки открыт для отправки');
} catch (error) {
showFeedback(error?.message || 'Не удалось открыть системное меню отправки', 'error');
}
close();
});
shell.querySelector('[data-action="send"]')?.addEventListener('click', async () => {
try {
const sender = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
if (typeof sender !== 'function') {
throw new Error('Отправка в лог на сервере недоступна');
}
const ok = await sender(buildSendPayload(report));
if (!ok) {
throw new Error('Не удалось отправить ошибку в лог на сервере');
}
showFeedback('Ошибка отправлена в лог на сервере');
} catch (error) {
showFeedback(error?.message || 'Не удалось отправить ошибку в лог на сервере', 'error');
}
close();
});
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
dialog?.focus?.();
window.addEventListener('keydown', onKeyDown);
} catch (error) {
console.warn('boot error menu failed', error);
}
};
const show = (title, text, extra = {}) => {
try {
const reportState = {
kind: safeString(extra.kind || title || 'boot_error', 64),
title: safeString(title || 'BOOT ERROR', 128),
message: safeString(extra.message || text || '', 500),
stack: safeString(extra.stack || '', 8000),
sourceUrl: safeString(extra.sourceUrl || extra.filename || '', 240),
lineNumber: Number.isFinite(extra.lineNumber) ? extra.lineNumber : (Number.isFinite(extra.lineno) ? extra.lineno : null),
columnNumber: Number.isFinite(extra.columnNumber) ? extra.columnNumber : (Number.isFinite(extra.colno) ? extra.colno : null),
reasonType: safeString(extra.reasonType || '', 64),
clientTs: Number.isFinite(extra.clientTs) ? extra.clientTs : Date.now(),
};
setState(reportState);
let el = document.getElementById('boot-error-overlay');
if (!el) {
el = document.createElement('pre');
@@ -72,17 +348,46 @@ window.__SHINE_BUILD_HASH__ = '20260806223040';
el.style.lineHeight = '1.4';
el.style.zIndex = '999999';
el.style.whiteSpace = 'pre-wrap';
el.style.cursor = 'pointer';
el.style.userSelect = 'none';
el.style.webkitUserSelect = 'none';
el.style.touchAction = 'manipulation';
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-haspopup', 'dialog');
document.body.appendChild(el);
el.addEventListener('click', openMenu);
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openMenu();
}
});
}
el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
el.setAttribute('aria-label', `${title}. Нажмите, чтобы открыть меню действий.`);
el.title = 'Нажмите, чтобы открыть меню действий';
} catch {}
};
window.addEventListener('error', (e) => {
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`);
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
kind: 'window_error',
message: e?.message || '',
stack: e?.error?.stack || '',
sourceUrl: e?.filename || '',
lineNumber: e?.lineno,
columnNumber: e?.colno,
reasonType: e?.error?.constructor?.name || e?.constructor?.name || 'ErrorEvent',
});
});
window.addEventListener('unhandledrejection', (e) => {
const reason = e?.reason;
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'));
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'), {
kind: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
stack: reason?.stack || '',
reasonType: reason?.constructor?.name || typeof reason,
});
});
}());
</script>
+116 -2
View File
@@ -5,7 +5,12 @@ import {
syncTrackedRouteHistory,
} from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
import {
captureClientError,
getLastClientErrorPayload,
setClientErrorSentNotifier,
setClientErrorTransport,
} from './services/client-error-reporter.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
@@ -82,7 +87,7 @@ import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202607152145';
import * as chatView from './pages/chat-view.js?v=202608191738';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
@@ -208,6 +213,10 @@ setClientErrorSentNotifier((payload) => {
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
});
window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ = (payload) => authService.reportClientError(payload);
window.__SHINE_SEND_CLIENT_ERROR__ = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
window.__SHINE_GET_LAST_CLIENT_ERROR__ = () => getLastClientErrorPayload();
window.__SHINE_SHOW_TOAST__ = (message, kind = 'success') => showToast(message, { kind });
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
@@ -221,6 +230,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
setShellMetricVar('--keyboard-offset', valuePx);
}
let stableViewportHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || window.visualViewport?.height || 0),
);
function isTextEntryFocused() {
const active = document.activeElement;
return active instanceof HTMLTextAreaElement
|| (active instanceof HTMLInputElement && !['button', 'checkbox', 'radio', 'range', 'file', 'submit', 'reset'].includes(active.type));
}
function syncViewportMetrics() {
if (!appShellEl) return;
const viewport = window.visualViewport || null;
const currentLayoutHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || viewport?.height || 0),
);
const widthPx = Math.max(1, Math.round(window.innerWidth || viewport?.width || 0));
const offsetLeftPx = Math.max(0, Math.round(viewport?.offsetLeft || 0));
const textEntryFocused = isTextEntryFocused();
// Android Chrome/Firefox могут уменьшать и visualViewport, и innerHeight.
// Поэтому высоту экрана до открытия клавиатуры запоминаем отдельно и во
// время ввода НЕ переписываем ею app-shell. Иначе toolbar тоже поднимется.
if (!textEntryFocused) {
stableViewportHeightPx = Math.max(stableViewportHeightPx, currentLayoutHeightPx);
// После поворота/реального resize разрешаем уменьшить базу, но только
// когда никакое текстовое поле не держит экранную клавиатуру.
if (Math.abs(stableViewportHeightPx - currentLayoutHeightPx) > 220) {
stableViewportHeightPx = currentLayoutHeightPx;
}
} else if (currentLayoutHeightPx > stableViewportHeightPx) {
stableViewportHeightPx = currentLayoutHeightPx;
}
const visualBottomPx = viewport
? Math.round((viewport.offsetTop || 0) + viewport.height)
: currentLayoutHeightPx;
const rawKeyboardOffsetPx = Math.max(
0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
);
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx
: 0;
setShellMetricVar('--app-viewport-width', widthPx);
setShellMetricVar('--app-viewport-height', keyboardOffsetPx > 0 ? stableViewportHeightPx : currentLayoutHeightPx);
setShellMetricVar('--app-viewport-offset-top', 0);
setShellMetricVar('--app-viewport-offset-left', offsetLeftPx);
setKeyboardOffsetPx(keyboardOffsetPx);
appShellEl.classList.toggle('keyboard-open', keyboardOffsetPx > 0);
}
function attachSlotHeightObserver(slotEl, cssVarName) {
if (!slotEl || typeof ResizeObserver !== 'function') return null;
const sync = () => {
@@ -237,6 +302,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
syncViewportMetrics();
window.visualViewport?.addEventListener('resize', syncViewportMetrics);
window.visualViewport?.addEventListener('scroll', syncViewportMetrics);
window.addEventListener('resize', syncViewportMetrics);
document.addEventListener('focusin', () => {
requestAnimationFrame(syncViewportMetrics);
// Samsung One UI / Firefox can finish the OSK viewport transition several
// frames after focus. Re-sample through the animation instead of trusting
// the first resize event.
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 180);
window.setTimeout(syncViewportMetrics, 320);
});
document.addEventListener('focusout', () => {
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 220);
});
// Optional on-device viewport diagnostics: append ?keyboard-debug=1 to the URL.
if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
const debugEl = document.createElement('pre');
debugEl.id = 'keyboard-viewport-debug';
Object.assign(debugEl.style, {
position: 'fixed', top: '4px', right: '4px', zIndex: '999999', margin: '0',
maxWidth: '94vw', padding: '6px 8px', fontSize: '10px', lineHeight: '1.25',
color: '#fff', background: 'rgba(0,0,0,.82)', pointerEvents: 'none',
whiteSpace: 'pre-wrap',
});
document.body.append(debugEl);
const syncDebug = () => {
const vv = window.visualViewport;
debugEl.textContent = [
`innerHeight=${window.innerHeight}`,
`clientHeight=${document.documentElement.clientHeight}`,
`vv.height=${vv ? Math.round(vv.height) : 'n/a'}`,
`vv.offsetTop=${vv ? Math.round(vv.offsetTop) : 'n/a'}`,
`stable=${stableViewportHeightPx}`,
`keyboard=${getComputedStyle(appShellEl).getPropertyValue('--keyboard-offset').trim()}`,
`focused=${isTextEntryFocused()}`,
].join(' | ');
};
window.visualViewport?.addEventListener('resize', syncDebug);
window.visualViewport?.addEventListener('scroll', syncDebug);
window.addEventListener('resize', syncDebug);
document.addEventListener('focusin', () => window.setTimeout(syncDebug, 330));
document.addEventListener('focusout', () => window.setTimeout(syncDebug, 230));
syncDebug();
}
function clearSlot(slotEl, cssVarName) {
if (!slotEl) return;
slotEl.innerHTML = '';
+242 -4
View File
@@ -234,6 +234,203 @@ function buildThreadRoute(messageRef, selector) {
});
}
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(ownerBlockchainName || '').trim();
const name = String(channelName || '').trim();
if (!ownerBch || !name) return '';
return `${ownerBch}/${name}`;
}
function getChannelScrollRoot() {
return document.getElementById('app-screen');
}
function scrollRootBy(delta, smooth = false) {
const root = getChannelScrollRoot();
const behavior = smooth ? 'smooth' : 'auto';
if (root && typeof root.scrollBy === 'function') {
root.scrollBy({ top: delta, behavior });
return;
}
window.scrollBy({ top: delta, behavior });
}
function getUnreadAnchorViewportFraction(unreadCount = 0) {
const count = Math.max(0, Number(unreadCount || 0));
if (count <= 1) return 0.68;
if (count <= 3) return 0.56;
if (count <= 7) return 0.48;
return 0.42;
}
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
if (!element) return false;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
const rect = element.getBoundingClientRect();
const delta = rect.top - targetTop;
if (Math.abs(delta) < 2) return true;
scrollRootBy(delta, smooth);
return true;
}
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
return scrollElementToViewportFraction(
screen.querySelector('.channel-unread-line'),
getUnreadAnchorViewportFraction(unreadCount),
smooth,
);
}
function createChannelReadTracker({
screen,
routeKey,
settingKey,
unreadCount,
messagesCount,
initialSeenCount,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const canWrite = !!(settingKey && login && storagePwd);
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let inFlight = false;
let disposed = false;
let rafId = 0;
let timerId = 0;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
};
const queueFlush = (delayMs = 180) => {
if (disposed || !canWrite) return;
clearTimer();
timerId = setTimeout(() => {
timerId = 0;
void flush();
}, Math.max(0, Number(delayMs) || 0));
};
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return;
if (inFlight) {
queueFlush(120);
return;
}
inFlight = true;
try {
await authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd,
});
persistedSeenCount = next;
} catch {
queueFlush(800);
} finally {
inFlight = false;
}
};
const collectSeenCount = () => {
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
if (!cards.length) return safeInitialSeenCount;
if (!unreadLine) return safeMessagesCount;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
let seen = safeInitialSeenCount;
for (const card of cards) {
const localNumber = Number(card.dataset.localNumber || 0);
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
const rect = card.getBoundingClientRect();
if (rect.top > thresholdTop + 1) break;
seen = Math.max(seen, localNumber);
}
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
};
const measure = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = collectSeenCount();
if (next > desiredSeenCount) {
desiredSeenCount = next;
queueFlush(180);
}
});
};
const scrollRoot = getChannelScrollRoot();
const onScroll = () => measure();
const onResize = () => measure();
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
} else {
window.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
if (initialSyncRequired) {
void authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: safeMessagesCount,
storagePwd,
}).catch(() => {});
persistedSeenCount = safeMessagesCount;
desiredSeenCount = safeMessagesCount;
} else {
window.setTimeout(() => measure(), 120);
}
const cleanup = () => {
disposed = true;
clearTimer();
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
scrollRoot.removeEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
};
return {
cleanup,
measure,
};
}
function firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
@@ -1249,6 +1446,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
async function loadFromApi(route, channelId) {
const currentSessionLogin = String(state.session.login || '').trim();
const isAuthorized = !!currentSessionLogin;
let unreadCount = 0;
let messagesCount = 0;
let cachedFeed = null;
const ensureFeed = async () => {
if (cachedFeed) return cachedFeed;
@@ -1309,6 +1508,9 @@ async function loadFromApi(route, channelId) {
}
if (selector?.ownerBlockchainName && selector?.channelName) {
let unreadCount = 0;
let messagesCount = 0;
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
@@ -1358,6 +1560,8 @@ async function loadFromApi(route, channelId) {
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.');
}
unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || 0);
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
@@ -1374,6 +1578,7 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
let reverseChannelMissingWarning = '';
let mergedMessages = [...messages];
if (!messagesCount) messagesCount = mergedMessages.length;
const currentLogin = currentSessionLogin;
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
@@ -1433,6 +1638,7 @@ async function loadFromApi(route, channelId) {
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(),
@@ -1445,6 +1651,8 @@ async function loadFromApi(route, channelId) {
posts,
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
reverseChannelMissingWarning,
unreadCount,
messagesCount,
isOwnChannel,
isSubscribed,
selector,
@@ -1733,6 +1941,9 @@ function renderPostCard(post, {
if (refKey) {
card.dataset.messageKey = refKey;
}
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
card.dataset.localNumber = String(Number(post.localNumber));
}
card.classList.add('is-counters-visible');
if (!post.messageRef || !selector) return card;
@@ -1898,6 +2109,10 @@ function renderPostCard(post, {
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
const readCount = Math.max(0, messagesCount - unreadCount);
if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta';
@@ -1923,6 +2138,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const postsByKey = new Map();
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
.map((event) => mapChannelMetaEvent(event, channelData.channel));
let unreadLineInserted = unreadCount === 0;
const feedItems = [
...metaEvents.map((event) => ({
type: 'meta',
@@ -1944,6 +2160,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (feedItems.length) {
feedItems.forEach((item) => {
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = 'Не прочитанные сообщения';
feed.append(unreadLine);
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
return;
@@ -1995,10 +2218,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
return () => {
// noop
};
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
unreadCount,
messagesCount,
initialSeenCount: readCount,
});
return tracker.cleanup;
}
function renderSkeleton(screen) {
+5 -48
View File
@@ -1219,7 +1219,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
@@ -1230,38 +1229,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const count = document.createElement('span');
count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0);
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger';
menuButton.textContent = '…';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(menuButton);
listState.revealedCounters.add(channel.id);
if (listState.openMenuId === channel.id) {
closeChannelMenu(listState);
rerenderList();
return;
}
listState.openMenuId = channel.id;
openChannelMenu({
listState,
channel,
anchorEl: menuButton,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
rerenderList,
});
rerenderList();
});
controls.append(menuButton);
if (unreadCount > 0) {
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
controls.append(count);
}
controls.append(time, count);
controls.append(time);
row.append(avatar, main, controls);
row.addEventListener('click', () => {
@@ -1278,14 +1250,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list);
}
function updateBottomCta({ button }) {
if (!button) return;
button.hidden = true;
button.textContent = '';
button.className = 'channels-bottom-action';
button.onclick = null;
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
@@ -1431,9 +1395,6 @@ export function render({ navigate, route, chrome }) {
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
topBarEl.append(topBarLeft, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
@@ -1454,19 +1415,15 @@ export function render({ navigate, route, chrome }) {
createInMyBtn.style.display = '';
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
updateBottomCta({ button: bottomCta });
};
chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
screen.append(contentEl);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
+89 -54
View File
@@ -572,12 +572,27 @@ function scrollToUnreadSeparator(list) {
return true;
}
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
function renderLog(
list,
chatId,
{
onOpenActions,
markAsRead = true,
scrollMode = 'latest',
showUnreadSeparator = true,
unreadSeparatorMessageKey = '',
} = {},
) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
messages.forEach((msg) => {
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
if (isUnreadBoundary) {
const sep = document.createElement('div');
sep.className = 'chat-unread-separator';
const label = document.createElement('span');
@@ -781,12 +796,8 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
}
}
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
}
export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -800,12 +811,53 @@ export function render({ navigate, route, chrome }) {
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true;
let historyLoading = false;
let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = '';
let historyBootstrapped = false;
let boundScrollContainer = null;
let unreadSeparatorVisible = hasUnreadIncoming;
let unreadSeparatorHideTimer = null;
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
const clearUnreadSeparatorHideTimer = () => {
if (unreadSeparatorHideTimer) {
window.clearTimeout(unreadSeparatorHideTimer);
unreadSeparatorHideTimer = null;
}
};
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead,
scrollMode,
showUnreadSeparator: unreadSeparatorVisible,
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
};
const hideUnreadSeparator = ({ rerender = true } = {}) => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorVisible = false;
if (rerender) {
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
const scheduleUnreadSeparatorAutoHide = () => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorHideTimer = window.setTimeout(() => {
hideUnreadSeparator({ rerender: true });
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
};
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
@@ -819,13 +871,13 @@ export function render({ navigate, route, chrome }) {
const handleStartCall = async (mode = 'audio') => {
try {
await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
@@ -848,11 +900,7 @@ export function render({ navigate, route, chrome }) {
unread: false,
rawBlobB64: String(result?.localBlobB64 || ''),
});
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
renderChatLog({ scrollMode: 'latest', markAsRead: false });
notifyUnreadStateUpdated();
};
@@ -1010,29 +1058,6 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const appShell = document.querySelector('.app-shell');
const setKeyboardInset = (valuePx = 0) => {
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
};
const syncKeyboardUi = () => {
const viewport = window.visualViewport || null;
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
const keyboardInset = viewportShrunk
? Math.max(0, baseViewportHeight - viewportHeight)
: 0;
setKeyboardInset(keyboardInset);
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (viewportShrunk && window.scrollY !== 0) {
window.scrollTo(0, 0);
}
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
};
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
@@ -1235,20 +1260,21 @@ export function render({ navigate, route, chrome }) {
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
};
const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
hideUnreadSeparator({ rerender: false });
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
@@ -1288,7 +1314,7 @@ export function render({ navigate, route, chrome }) {
cancelReplyMode({ restoreDraft: false });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) {
notifyUnreadStateUpdated();
}
@@ -1331,7 +1357,7 @@ export function render({ navigate, route, chrome }) {
error: e?.message || 'unknown',
},
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
@@ -1393,7 +1419,7 @@ export function render({ navigate, route, chrome }) {
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true;
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) {
window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
@@ -1445,12 +1471,12 @@ export function render({ navigate, route, chrome }) {
input?.addEventListener('focus', () => {
rememberEmojiSelection();
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log);
window.requestAnimationFrame(() => {
if (inputFocused) scrollToLatestMessage(log);
});
});
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
@@ -1493,25 +1519,34 @@ export function render({ navigate, route, chrome }) {
const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return;
if (Number(event?.detail?.messageType || 0) === 1) {
if (!unreadSeparatorVisible) {
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
}
scheduleUnreadSeparatorAutoHide();
}
preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
renderChatLog({ scrollMode: 'latest' });
});
window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId);
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
chrome?.setComposer(form);
wrap.append(historyLoader, log);
screen.append(wrap);
chrome?.setComposer(form);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
showUnreadSeparator: unreadSeparatorVisible,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
window.setTimeout(() => {
if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated();
@@ -1519,18 +1554,18 @@ export function render({ navigate, route, chrome }) {
}, 220);
void sendReadReceiptsForVisible(chatId);
window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
boundScrollContainer = wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: true });
});
screen.cleanup = () => {
setChatKeyboardOpen(false);
setKeyboardInset(0);
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
};
return screen;
}
+250 -62
View File
@@ -1,21 +1,28 @@
import { directMessages } from '../mock-data.js';
import {
getChatMessages,
authService,
isSessionInvalidError,
normalizeDmChatId,
setContacts,
state,
terminateCurrentSession,
} from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([
['close_friend', 0],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
@@ -65,10 +72,72 @@ function createDmAvatar(login) {
return avatarEl;
}
function resolveLastMessagePreview(text = '') {
const parsed = parseDmTechBlocks(String(text || ''));
const display = String(parsed.displayText || '').trim();
return display || '';
function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'contact') return clean;
return 'none';
}
function relationOrder(flag) {
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
}
function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'contact':
return 'контакт';
default:
return 'не в контактах';
}
}
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}`;
}
async function resolveDialogPreview(dialog) {
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
if (!blobB64) return 'Диалог пока пуст.';
const cacheKey = [
blobB64,
String(state.session.login || '').trim().toLowerCase(),
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
const pending = (async () => {
try {
const parsed = authService.parseSignedMessageBlob(blobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
blobB64,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
const display = clipPreviewText(String(parsedText.displayText || '').trim());
const result = display || 'Сообщение';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = 'Сообщение недоступно';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
}
})();
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
return pending;
}
function formatChatRowTime(ts) {
@@ -82,6 +151,15 @@ function formatChatRowTime(ts) {
}).format(new Date(value));
}
function compareChatRows(a, b) {
const timeA = Number(a?.lastMessageTimeMs || 0);
const timeB = Number(b?.lastMessageTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.peerLogin || '').toLowerCase();
const nameB = String(b?.peerLogin || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru');
}
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate, chrome }) {
@@ -98,23 +176,116 @@ export function render({ navigate, chrome }) {
<span class="dm-head-name"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
<h1 class="dm-head-title">Чаты</h1>
<div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
</div>
</div>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const divider = document.createElement('div');
divider.className = 'dm-divider';
const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
// header bounds. A dropdown overflowing below it can look visible but clicks may
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const list = document.createElement('div');
list.className = 'stack dm-list';
function renderRow(item) {
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id);
const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin);
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
@@ -123,14 +294,14 @@ export function render({ navigate, chrome }) {
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
<span class="dm-contact-note">${relationBadge}</span>
</div>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line">
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
</div>
@@ -138,11 +309,15 @@ export function render({ navigate, chrome }) {
const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.name || '');
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
if (timeEl) timeEl.textContent = String(item.time || '');
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row;
}
@@ -156,60 +331,64 @@ export function render({ navigate, chrome }) {
}
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
const payload = await authService.listContacts();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
setContacts(contacts);
list.innerHTML = '';
const contactRows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const canonicalLogin = normalizeDmChatId(login);
const chat = getChatMessages(canonicalLogin);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: canonicalLogin,
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: false,
const byPeer = new Map();
dialogs.forEach((dialog) => {
const peerLogin = String(dialog?.peerLogin || '').trim();
if (!peerLogin) return;
const key = peerLogin.toLowerCase();
const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
const next = {
id: peerLogin,
peerLogin,
relationFlag,
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unreadCount: Number(dialog?.unreadCount || 0),
hasDialog: Boolean(dialog?.hasDialog),
};
const current = byPeer.get(key);
if (!current) {
byPeer.set(key, next);
return;
}
const currentRank = relationOrder(current.relationFlag);
const nextRank = relationOrder(relationFlag);
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
byPeer.set(key, next);
}
});
const allChatIds = Object.keys(state.chats || {})
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
.filter((id) => (getChatMessages(id) || []).length > 0);
const rows = Array.from(byPeer.values()).sort((a, b) => {
const orderA = relationOrder(a.relationFlag);
const orderB = relationOrder(b.relationFlag);
if (orderA !== orderB) return orderA - orderB;
return compareChatRows(a, b);
});
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
const extraRows = allChatIds
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
.map((login) => {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
};
});
const rows = [...contactRows, ...extraRows];
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ни контактов, ни сообщений';
empty.textContent = 'Пока нет диалогов';
list.append(empty);
return;
}
rows.forEach((item) => list.append(renderRow(item)));
let dividerInserted = false;
rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item));
});
} catch (error) {
if (isSessionInvalidError(error)) {
list.innerHTML = '';
@@ -249,7 +428,16 @@ export function render({ navigate, chrome }) {
}
chrome?.setTopbar(head);
screen.append(divider, list);
screen.append(list);
loadList();
screen.cleanup = () => {
closeHeadMenu();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen;
}
+8 -2
View File
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
@@ -426,7 +426,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
form,
actions,
+12 -2
View File
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -102,7 +103,10 @@ export function render({ navigate }) {
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
cancelButton.addEventListener('click', () => {
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
@@ -190,7 +194,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Сохранение ключей',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
card,
actions,
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
replacement.addEventListener('click', () => {
stageClosed = true;
stopTimers();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
replacement.addEventListener('click', () => {
loginCompleted = true;
stopAutoLogin();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
+78 -1
View File
@@ -153,6 +153,10 @@ function makeClientPlatform() {
return 'Web';
}
function escapeUserSettingPart(value = '') {
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
}
function hexToBytes(hex) {
const clean = String(hex || '').trim().toLowerCase();
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
@@ -505,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
};
}
function extractContactsFromDialogs(dialogs) {
const seen = new Set();
const out = [];
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
const login = String(dialog?.peerLogin || '').trim();
if (!login) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -2848,7 +2868,11 @@ export class AuthService {
async listContacts() {
const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response);
return response.payload || {};
const payload = response.payload || {};
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
payload.contacts = extractContactsFromDialogs(payload.dialogs);
}
return payload;
}
@@ -2898,6 +2922,59 @@ export class AuthService {
return response.payload || {};
}
async upsertUserSetting({
login,
settingType,
settingKey,
timeMs,
valueText = '',
valueNum = 0,
storagePwd,
syncDelivery = false,
}) {
const cleanLogin = String(login || '').trim();
const cleanSettingKey = String(settingKey || '').trim();
const cleanValueText = String(valueText ?? '');
const cleanTimeMs = Number(timeMs);
const cleanSettingType = Number(settingType);
const cleanValueNum = Number(valueNum ?? 0);
if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey');
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs');
if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.');
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const preimage = [
'SHiNe/UserSettings:',
escapeUserSettingPart(cleanLogin),
String(cleanSettingType),
escapeUserSettingPart(cleanSettingKey),
String(Math.trunc(cleanTimeMs)),
escapeUserSettingPart(cleanValueText),
String(Math.trunc(cleanValueNum)),
].join('|');
const signature = await signBase64(privateKey, preimage);
const response = await this.ws.request('UpsertUserSetting', {
login: cleanLogin,
setting_type: Math.trunc(cleanSettingType),
setting_key: cleanSettingKey,
time_ms: Math.trunc(cleanTimeMs),
value_text: cleanValueText,
value_num: Math.trunc(cleanValueNum),
client_key: clientKey,
signature,
sync_delivery: !!syncDelivery,
});
if (response.status !== 200) throw opError('UpsertUserSetting', response);
return response.payload || {};
}
async getTestFreeAvatarQuota() {
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
@@ -6,6 +6,7 @@ let transport = null;
let transportDepth = 0;
const recentFingerprints = new Map();
let notifySent = null;
let lastCapturedPayload = null;
function nowTs() {
return Date.now();
@@ -85,6 +86,11 @@ export function setClientErrorSentNotifier(fn) {
notifySent = typeof fn === 'function' ? fn : null;
}
export function getLastClientErrorPayload() {
if (!lastCapturedPayload) return null;
return { ...lastCapturedPayload };
}
export function isClientErrorReportingEnabled() {
try {
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
@@ -104,6 +110,7 @@ export function setClientErrorReportingEnabled(enabled) {
export async function captureClientError(details = {}) {
const payload = buildPayload(details);
if (!payload.message) return false;
lastCapturedPayload = payload;
const fingerprint = details.dedupeKey || makeFingerprint(payload);
if (isDuplicate(fingerprint)) return false;
+8
View File
@@ -925,6 +925,14 @@ export async function refreshSessions() {
return state.sessions;
}
export function resetRegistrationFlow() {
const next = createInitialState();
state.registrationDraft = next.registrationDraft;
state.registrationHelp = next.registrationHelp;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
}
function resetStateForSignedOut() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
+208 -22
View File
@@ -3324,6 +3324,10 @@ textarea.input {
z-index: 24;
}
.boot-error-menu-shell {
z-index: 1000000;
}
.modal-backdrop {
position: absolute;
inset: 0;
@@ -3341,6 +3345,16 @@ textarea.input {
box-shadow: var(--shadow);
}
.boot-error-menu-card {
gap: 10px;
}
.boot-error-menu-message {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.4;
}
.network-board {
position: relative;
height: 290px;
@@ -4248,6 +4262,36 @@ textarea.input {
gap: 10px;
}
.channel-unread-line {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0 12px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(244, 202, 102, 0.46);
background:
linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9));
color: #ffe6a7;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
text-align: center;
justify-content: center;
box-shadow: 0 0 0 1px rgba(255, 226, 155, 0.08), 0 10px 24px rgba(6, 10, 20, 0.35);
}
.channel-unread-line::before,
.channel-unread-line::after {
content: '';
flex: 1 1 0;
height: 1px;
min-width: 18px;
background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent);
}
.channels-screen--channel .channel-feed {
gap: 2px;
margin-left: -7px;
@@ -6268,6 +6312,37 @@ html, body { overflow-x: hidden; }
gap: 12px;
}
.dm-chat-screen {
display: flex;
flex-direction: column;
min-height: 0;
gap: 12px;
}
.dm-chat-screen > .dm-chat-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.dm-chat-composer {
position: sticky;
bottom: 0;
z-index: 12;
flex: 0 0 auto;
}
.screen-content:has(> .dm-chat-screen) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.screen-content:has(> .dm-chat-screen) > .dm-chat-screen {
flex: 1 1 auto;
min-height: 0;
}
.screen-content:has(> .dm-screen) {
scrollbar-width: none;
-ms-overflow-style: none;
@@ -6282,27 +6357,14 @@ html, body { overflow-x: hidden; }
.screen-content:has(> .dm-chat-screen) {
padding-top: 0;
padding-bottom: 0;
scrollbar-width: thin;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
scrollbar-width: none;
-ms-overflow-style: none;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar {
width: 4px;
height: 4px;
display: block;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.7);
border-radius: 999px;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb:hover {
background: rgba(240, 198, 76, 0.9);
width: 0;
height: 0;
display: none;
}
.dm-messages-log {
@@ -6401,11 +6463,10 @@ html, body { overflow-x: hidden; }
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
position: sticky;
bottom: 0;
position: relative;
z-index: 10;
margin-inline: -14px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
margin-inline: 0;
padding: 10px 0;
border-top: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(12px);
@@ -7900,3 +7961,128 @@ html, body { overflow-x: hidden; }
color: #D4AF37;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
}
/* Contacts header overflow menu — vertical ellipsis + glass dropdown. */
.dm-head-menu-wrap {
position: relative;
justify-self: end;
display: grid;
place-items: center;
z-index: 40;
}
.dm-head-menu-btn {
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 16px;
display: grid;
place-items: center;
color: #FFD98A;
background: transparent;
cursor: pointer;
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.dm-head-menu-btn:active { transform: scale(0.94); }
.dm-head-menu-wrap.is-open .dm-head-menu-btn,
.dm-head-menu-btn:focus-visible {
outline: none;
background: rgba(240, 184, 46, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 184, 46, 0.24), 0 0 18px rgba(240, 184, 46, 0.12);
}
.dm-head-menu-dots {
width: 6px;
height: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3.5px;
}
.dm-head-menu-dots i {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 7px rgba(240, 184, 46, 0.45);
}
.dm-head-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
width: max-content;
min-width: 190px;
padding: 6px;
border: 1px solid rgba(240, 184, 46, 0.26);
border-radius: 15px;
background: linear-gradient(155deg, rgba(22, 24, 31, 0.97), rgba(10, 12, 18, 0.97));
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42), 0 0 20px rgba(240, 184, 46, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transform-origin: top right;
animation: dm-head-menu-in 140ms ease-out both;
}
.dm-head-menu[hidden] { display: none; }
.dm-head-menu::before {
content: '';
position: absolute;
top: -5px;
right: 18px;
width: 9px;
height: 9px;
transform: rotate(45deg);
border-left: 1px solid rgba(240, 184, 46, 0.22);
border-top: 1px solid rgba(240, 184, 46, 0.22);
background: rgba(19, 21, 28, 0.98);
}
.dm-head-menu-item {
position: relative;
z-index: 1;
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
color: #FCEAC0;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 600;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.dm-head-menu-item svg {
width: 19px;
height: 19px;
flex: 0 0 auto;
color: #E7B83D;
filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.22));
}
.dm-head-menu-item:hover,
.dm-head-menu-item:focus-visible,
.dm-head-menu-item:active {
outline: none;
background: rgba(240, 184, 46, 0.09);
}
@keyframes dm-head-menu-in {
from { opacity: 0; transform: translateY(-5px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.dm-head-menu { animation: none; }
.dm-head-menu-btn { transition: none; }
}
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
.dm-head-menu--portal {
position: fixed;
right: auto;
z-index: 10000;
pointer-events: auto;
isolation: isolate;
}
+53 -22
View File
@@ -2,7 +2,7 @@ body {
display: flex;
justify-content: center;
background: #05070A;
min-height: 100vh;
min-height: 100dvh;
position: relative;
overflow: hidden;
}
@@ -22,12 +22,11 @@ body::before {
}
.app-shell {
width: min(100vw, 430px);
height: 100vh;
height: 100svh;
width: min(var(--app-viewport-width, 100vw), 430px);
height: var(--app-viewport-height, 100vh);
position: fixed;
top: 0;
left: 50%;
top: var(--app-viewport-offset-top, 0px);
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
transform: translateX(-50%);
--call-minimized-bar-height: 0px;
--topbar-height: 0px;
@@ -94,10 +93,11 @@ body::before {
.composer-slot {
position: absolute;
left: 0;
right: 0;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px 8px;
padding: 0 12px;
pointer-events: none;
}
@@ -115,20 +115,42 @@ body::before {
transition: opacity 0.18s ease, transform 0.18s ease;
}
body.chat-keyboard-open .screen-content {
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)));
padding-bottom: calc(14px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .composer-slot {
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px));
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .toolbar-slot {
opacity: 1;
pointer-events: auto;
body.chat-topbar-overlay .topbar-slot {
position: fixed;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
width: min(100vw, 430px);
margin: 0 auto;
transform: none;
padding: 0 12px;
}
body.chat-topbar-overlay .screen-content {
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
}
body.chat-topbar-overlay .composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
}
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
visualViewport, а область сообщений заканчивается прямо над composer. */
.app-shell.keyboard-open .toolbar-slot {
bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
bottom: var(--keyboard-offset, 0px);
}
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
}
.connection-retry-banner {
@@ -177,3 +199,12 @@ body.chat-keyboard-open .toolbar-slot {
border-radius: 24px;
}
}
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
padding-bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
padding-bottom: 0;
}