Compare commits

..
Author SHA256 Message Date
AidarKC 01321f5200 Добавили счётчик подписчиков в каналах и подписки пользователя 2026-08-26 11:11:46 +04:00
AidarKC 3a5851939e Починить межсерверную репликацию личных сообщений
Репликация личных сообщений между серверами теперь работает корректно.
2026-08-25 19:31:38 +04:00
AidarKC 7d929e626e Исправить определение клиентского DM-вызова 2026-08-25 19:30:44 +04:00
AidarKC d8e0c77951 Сохранить текущий снимок DM-синхронизации 2026-08-24 17:50:36 +04:00
AidarKC 015fade4c0 Смерджить main в ветку расширения функционала каналов 2026-08-22 20:16:16 +04:00
AidarKC da14b51e80 Исправить стартовый bootstrap DM 2026-08-22 20:13:14 +04:00
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
tram 0f30317bb4 Поднять бейдж канала и опустить время 2026-08-22 17:00:26 +03:00
tram 1f1bc0a7b9 Убрать меню из строк каналов 2026-08-22 16:51:37 +03:00
tram 60c8a6608a Вернуть channels-list к версии ui-artem 2026-08-22 16:47:12 +03:00
tram 86adaf8c6b Слить main с актуальными UI-изменениями 2026-08-22 16:36:47 +03:00
tram 731688d16e Merge ui-artem into main 2026-08-22 16:35:44 +03:00
tram 63b66c48d8 Доточить позиционирование UI 2026-08-22 16:35:41 +03:00
tram ea19e511c0 Доработать аватары и меню UI 2026-08-22 13:27:08 +03:00
tram f5e401cff8 Пересобрать верхний UI-бар 2026-08-22 12:34:07 +03:00
tram bc8c7f318b Обновить UI-кнопки и прокрутку 2026-08-22 11:28:12 +03:00
AidarKC 585750007d Merge remote-tracking branch 'origin/main' 2026-08-21 15:16:42 +04:00
AidarKC 6e80ac976a Merge branch 'расширение-функционала-каналов' 2026-08-21 15:16:13 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
tram d3e6aa2be2 Добавить поддержку уведомлений 2026-08-21 14:11:47 +03:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
tram 19362b950a Доработать UI уведомлений 2026-08-21 14:06:19 +03: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
111 changed files with 9258 additions and 1222 deletions
@@ -27,6 +27,11 @@ public final class DatabaseInitializer {
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 int SCHEMA_VERSION_12 = 12;
public static final int SCHEMA_VERSION_13 = 13;
public static final int SCHEMA_VERSION_14 = 14;
public static final int SCHEMA_VERSION_15 = 15;
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";
@@ -37,6 +42,11 @@ public final class DatabaseInitializer {
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";
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
private DatabaseInitializer() {}
@@ -134,6 +144,27 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
currentVersion = SCHEMA_VERSION_10;
}
if (currentVersion < SCHEMA_VERSION_11) {
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
currentVersion = SCHEMA_VERSION_11;
}
if (currentVersion < SCHEMA_VERSION_12) {
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
currentVersion = SCHEMA_VERSION_12;
}
if (currentVersion < SCHEMA_VERSION_13) {
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
currentVersion = SCHEMA_VERSION_13;
}
if (currentVersion < SCHEMA_VERSION_14) {
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
currentVersion = SCHEMA_VERSION_14;
}
if (currentVersion < SCHEMA_VERSION_15) {
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
currentVersion = SCHEMA_VERSION_15;
}
}
}
@@ -1,6 +1,7 @@
package shine.db;
import shine.db.connection.DbProvider;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection;
import java.sql.SQLException;
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
private static volatile DbController instance;
private final PostgresDbController delegate;
private volatile boolean dmDialogStateBootstrapped;
private DbController() {
this.delegate = PostgresDbController.getInstance();
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
if (instance == null) {
synchronized (DbController.class) {
if (instance == null) {
instance = new DbController();
DbController created = new DbController();
instance = created;
created.bootstrapDmDialogStateIfNeeded();
}
}
}
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
public void close() {
delegate.close();
}
private void bootstrapDmDialogStateIfNeeded() {
if (dmDialogStateBootstrapped) return;
synchronized (this) {
if (dmDialogStateBootstrapped) return;
try (Connection connection = delegate.getConnection()) {
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
dmDialogStateBootstrapped = true;
} catch (SQLException e) {
instance = null;
throw new RuntimeException("DM dialog state bootstrap failed", e);
}
}
}
}
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
rebuildStatsState(c);
c.commit();
return new CleanupResult(
@@ -366,6 +368,100 @@ public final class BlockchainResyncCleanupDAO {
""", blockchainName);
}
private void rebuildStatsState(Connection c) throws SQLException {
try (PreparedStatement truncate = c.prepareStatement("""
TRUNCATE TABLE user_stats_state, channel_stats_state
""")) {
truncate.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0),
COALESCE(fu.following_users_count, 0),
COALESCE(fc.following_channels_count, 0),
COALESCE(cf.close_friends_count, 0),
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login)
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code
""")) {
ps.executeUpdate();
}
}
private int executeDelete(Connection c, String sql, String value) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, value);
@@ -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.
*/
@@ -0,0 +1,518 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmDeliveryStateEntry;
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.HashMap;
import java.util.List;
import java.util.Map;
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
public final class DmDeliveryStateDAO {
private static volatile DmDeliveryStateDAO instance;
private final DbController db = DbController.getInstance();
private DmDeliveryStateDAO() {}
public static DmDeliveryStateDAO getInstance() {
if (instance == null) {
synchronized (DmDeliveryStateDAO.class) {
if (instance == null) instance = new DmDeliveryStateDAO();
}
}
return instance;
}
public DmDeliveryStateEntry upsertPair(
String outgoingMessageKey,
String eventId,
String baseKey,
String fromLogin,
String toLogin,
String incomingMessageKey,
long createdAtMs,
long expiresAtMs,
int initialState,
String deliveredServerLogin,
String routesHash,
boolean assistImmediately
) throws SQLException {
long now = System.currentTimeMillis();
int safeState = normalizeState(initialState, deliveredServerLogin);
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
safeState = DmDeliveryStateEntry.FAILED_FINAL;
}
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
? now
: null;
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
? normalize(deliveredServerLogin)
: null;
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO dm_delivery_state (
outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
ON CONFLICT (outgoing_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
base_key = EXCLUDED.base_key,
from_login = EXCLUDED.from_login,
to_login = EXCLUDED.to_login,
incoming_message_key = EXCLUDED.incoming_message_key,
created_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
delivery_expires_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
ELSE EXCLUDED.delivery_expires_at_ms
END,
delivery_state = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
ELSE EXCLUDED.delivery_state
END,
delivered_server_login = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
ELSE EXCLUDED.delivered_server_login
END,
recipient_routes_hash = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
ELSE EXCLUDED.recipient_routes_hash
END,
attempt_index = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
ELSE 0
END,
next_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
ELSE EXCLUDED.next_attempt_at_ms
END,
last_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
ELSE NULL
END,
last_error = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
ELSE NULL
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, outgoingMessageKey);
ps.setString(2, eventId);
ps.setString(3, baseKey);
ps.setString(4, normalize(fromLogin));
ps.setString(5, normalize(toLogin));
ps.setString(6, incomingMessageKey);
ps.setLong(7, createdAtMs);
ps.setLong(8, expiresAtMs);
ps.setInt(9, safeState);
setNullableString(ps, 10, safeDeliveredLogin);
setNullableString(ps, 11, normalizeBlank(routesHash));
setNullableLong(ps, 12, nextAttemptAt);
ps.setLong(13, now);
ps.executeUpdate();
}
}
DmDeliveryStateEntry result;
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
} else {
result = getByEventId(eventId);
}
if (result != null && result.getDeliveryExpiresAtMs() <= now
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
return finishAtExpiry(eventId, now);
}
return result;
}
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
if (isBlank(eventId)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
ps.setString(1, eventId.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
if (isBlank(outgoingMessageKey)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
ps.setString(1, outgoingMessageKey.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
if (keys == null || keys.isEmpty()) return out;
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
DmDeliveryStateEntry row = mapRow(rs);
out.put(row.getOutgoingMessageKey(), row);
}
}
}
return out;
}
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
String sql = selectColumns() + """
WHERE delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
LIMIT ?
""";
List<DmDeliveryStateEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET attempt_index = attempt_index + 1,
next_attempt_at_ms = ?,
last_attempt_at_ms = ?,
last_error = NULL,
updated_at_ms = ?
WHERE outgoing_message_key = ?
AND event_id = ?
AND attempt_index = ?
AND delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
long networkLeaseUntilMs = nowMs + 60_000L;
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
? networkLeaseUntilMs
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
setNullableLong(ps, 1, claimedNextAttemptAtMs);
ps.setLong(2, nowMs);
ps.setLong(3, nowMs);
ps.setString(4, row.getOutgoingMessageKey());
ps.setString(5, row.getEventId());
ps.setInt(6, row.getAttemptIndex());
ps.setLong(7, nowMs);
return ps.executeUpdate() == 1;
}
}
public DmDeliveryStateEntry mergeRemote(
String eventId,
int remoteState,
String remoteDeliveredLogin,
String remoteRoutesHash,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(),
current.getDeliveredServerLogin(),
current.getRecipientRoutesHash(),
remoteState,
remoteDeliveredLogin,
remoteRoutesHash
);
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry updateAfterAttempt(
String eventId,
int attemptedState,
String deliveredServerLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
attemptedState, deliveredServerLogin, routesHash
);
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
? nextAttemptAtMs
: null;
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), effectiveNext, lastError, nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
next_attempt_at_ms = NULL,
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
}
return getByEventId(eventId);
}
/** Read-only peer confirmed that at least one recipient server accepted the message. */
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
last_error = NULL,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""")) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
return getByEventId(eventId);
}
public int removeByBaseKey(String baseKey) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
ps.setString(1, baseKey);
return ps.executeUpdate();
}
}
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
String sql = """
DELETE FROM dm_delivery_state
WHERE created_at_ms < ?
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, boundaryTimeMs);
ps.setString(2, fromLogin);
ps.setString(3, toLogin);
ps.setString(4, toLogin);
ps.setString(5, fromLogin);
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_delivery_state d
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
ps.setString(1, eventId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private void updateMutableState(
Connection c,
DmDeliveryStateEntry current,
int state,
String deliveredLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = ?,
delivered_server_login = ?,
recipient_routes_hash = ?,
next_attempt_at_ms = ?,
last_error = ?,
updated_at_ms = ?
WHERE outgoing_message_key = ? AND event_id = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, state);
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
setNullableString(ps, 3, normalizeBlank(routesHash));
setNullableLong(ps, 4, nextAttemptAtMs);
setNullableString(ps, 5, normalizeBlank(lastError));
ps.setLong(6, nowMs);
ps.setString(7, current.getOutgoingMessageKey());
ps.setString(8, current.getEventId());
ps.executeUpdate();
}
}
private MergeResult merge(
int localState,
String localLogin,
String localHash,
int remoteStateRaw,
String remoteLoginRaw,
String remoteHashRaw
) {
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
String remoteLogin = normalize(remoteLoginRaw);
String localNormalizedLogin = normalize(localLogin);
String localRoutesHash = normalizeBlank(localHash);
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
}
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
}
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
}
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
}
private int normalizeState(int state, String deliveredLogin) {
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
return DmDeliveryStateEntry.PENDING_NONE;
}
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
return state;
}
private String selectColumns() {
return """
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
FROM dm_delivery_state
""";
}
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
e.setEventId(rs.getString("event_id"));
e.setBaseKey(rs.getString("base_key"));
e.setFromLogin(rs.getString("from_login"));
e.setToLogin(rs.getString("to_login"));
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
e.setDeliveryState(rs.getInt("delivery_state"));
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
e.setAttemptIndex(rs.getInt("attempt_index"));
long next = rs.getLong("next_attempt_at_ms");
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
long last = rs.getLong("last_attempt_at_ms");
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
else ps.setString(index, value.trim());
}
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
if (value == null) ps.setNull(index, Types.BIGINT);
else ps.setLong(index, value);
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase();
return normalized.isEmpty() ? null : normalized;
}
private static String normalizeBlank(String value) {
if (value == null) return null;
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
}
@@ -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) {}
}
@@ -0,0 +1,232 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncOutboxEntry;
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;
/** Outbox событий DM для единственного второго access-сервера пользователя. */
public final class DmSyncOutboxDAO {
private static volatile DmSyncOutboxDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncOutboxDAO() {}
public static DmSyncOutboxDAO getInstance() {
if (instance == null) {
synchronized (DmSyncOutboxDAO.class) {
if (instance == null) instance = new DmSyncOutboxDAO();
}
}
return instance;
}
public void upsert(
String ownerLogin,
String primaryMessageKey,
String eventId,
String secondaryMessageKey,
boolean synced,
long createdAtMs
) throws SQLException {
long now = System.currentTimeMillis();
String sql = """
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
secondary_message_key = EXCLUDED.secondary_message_key,
synced = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.synced OR EXCLUDED.synced
ELSE EXCLUDED.synced
END,
created_at_ms = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, primaryMessageKey);
ps.setString(3, eventId);
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
else ps.setString(4, secondaryMessageKey.trim());
ps.setBoolean(5, synced);
ps.setLong(6, createdAtMs);
ps.setLong(7, now);
ps.executeUpdate();
}
}
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
return listUnsynced(ownerLogin, 0L, "", limit);
}
public List<DmSyncOutboxEntry> listUnsynced(
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
) throws SQLException {
String sql = """
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
FROM dm_sync_outbox
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
ORDER BY created_at_ms ASC, primary_message_key ASC
LIMIT ?
""";
List<DmSyncOutboxEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
ps.setInt(5, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
String sql = """
SELECT 1
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
String sql = """
SELECT DISTINCT o.owner_login
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
ORDER BY o.owner_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(rs.getString("owner_login"));
}
}
return result;
}
public int markSynced(String ownerLogin, String eventId) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
ps.setString(3, eventId);
return ps.executeUpdate();
}
}
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
if (eventIds == null || eventIds.isEmpty()) return 0;
int total = 0;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""")) {
long now = System.currentTimeMillis();
for (String eventId : eventIds) {
if (eventId == null || eventId.isBlank()) continue;
ps.setLong(1, now);
ps.setString(2, ownerLogin);
ps.setString(3, eventId.trim());
ps.addBatch();
}
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
}
return total;
}
public int markAllUnsynced(String ownerLogin) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = FALSE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
return ps.executeUpdate();
}
}
public int markAllUnsynced() throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
ps.setLong(1, System.currentTimeMillis());
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_sync_outbox o
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
e.setEventId(rs.getString("event_id"));
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
e.setSynced(rs.getBoolean("synced"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
@@ -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;
@@ -75,6 +75,35 @@ public final class UserAccessServersCurrentDAO {
return result;
}
/**
* Возвращает уникальные физические серверы из текущей routing-проекции.
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
* а не на каждого пользователя этого сервера.
*/
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
String sql = """
SELECT DISTINCT ON (LOWER(server_login))
server_login, server_url
FROM user_access_servers_current
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
ORDER BY LOWER(server_login), server_login, server_url
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin("");
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
result.add(entry);
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login"));
@@ -0,0 +1,84 @@
package shine.db.entities;
public class DmDeliveryStateEntry {
public static final int ACCEPTED = 0;
public static final int DELIVERED = 1;
public static final int FAILED = 3;
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
public static final int PENDING_NONE = ACCEPTED;
public static final int DELIVERED_ONE = DELIVERED;
public static final int DELIVERED_ALL = 2;
public static final int FAILED_FINAL = FAILED;
private String outgoingMessageKey;
private String eventId;
private String baseKey;
private String fromLogin;
private String toLogin;
private String incomingMessageKey;
private long createdAtMs;
private long deliveryExpiresAtMs;
private int deliveryState;
private String deliveredServerLogin;
private String recipientRoutesHash;
private int attemptIndex;
private Long nextAttemptAtMs;
private Long lastAttemptAtMs;
private String lastError;
private long updatedAtMs;
public String getOutgoingMessageKey() { return outgoingMessageKey; }
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String value) { this.baseKey = value; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String value) { this.fromLogin = value; }
public String getToLogin() { return toLogin; }
public void setToLogin(String value) { this.toLogin = value; }
public String getIncomingMessageKey() { return incomingMessageKey; }
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
public int getDeliveryState() { return deliveryState; }
public void setDeliveryState(int value) { this.deliveryState = value; }
public String getDeliveredServerLogin() { return deliveredServerLogin; }
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
public String getRecipientRoutesHash() { return recipientRoutesHash; }
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
public int getAttemptIndex() { return attemptIndex; }
public void setAttemptIndex(int value) { this.attemptIndex = value; }
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
public String getLastError() { return lastError; }
public void setLastError(String value) { this.lastError = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
public String deliveryStateCode() {
return switch (deliveryState) {
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
case FAILED_FINAL -> "failed";
default -> "accepted";
};
}
public static int parseStateCode(String value) {
if (value == null) return ACCEPTED;
return switch (value.trim().toLowerCase()) {
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
case "failed", "failed_final" -> FAILED;
default -> ACCEPTED;
};
}
public boolean isDelivered() {
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
}
}
@@ -0,0 +1,26 @@
package shine.db.entities;
public class DmSyncOutboxEntry {
private String ownerLogin;
private String primaryMessageKey;
private String eventId;
private String secondaryMessageKey;
private boolean synced;
private long createdAtMs;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String value) { this.ownerLogin = value; }
public String getPrimaryMessageKey() { return primaryMessageKey; }
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getSecondaryMessageKey() { return secondaryMessageKey; }
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
public boolean isSynced() { return synced; }
public void setSynced(boolean value) { this.synced = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
}
@@ -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,139 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_delivery_state (
outgoing_message_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
base_key TEXT NOT NULL,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
incoming_message_key TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
delivery_expires_at_ms BIGINT NOT NULL,
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
delivered_server_login TEXT,
recipient_routes_hash TEXT,
attempt_index INTEGER NOT NULL DEFAULT 0,
next_attempt_at_ms BIGINT,
last_attempt_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
ON dm_delivery_state(base_key, from_login);
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
owner_login TEXT NOT NULL,
primary_message_key TEXT NOT NULL,
event_id TEXT NOT NULL,
secondary_message_key TEXT,
synced BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, primary_message_key)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
WHERE synced = FALSE;
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
INSERT INTO dm_delivery_state (
outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
)
SELECT
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
outgoing.base_key,
outgoing.from_login,
outgoing.to_login,
incoming.message_key,
outgoing.created_at_ms,
outgoing.created_at_ms + 3600000,
2,
NULL,
NULL,
9,
NULL,
NULL,
'MIGRATED_AS_DELIVERED',
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (outgoing_message_key) DO NOTHING;
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(outgoing.from_login),
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
incoming.message_key,
TRUE,
outgoing.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Входящая копия синхронизируется отдельно между серверами получателя.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(incoming.to_login),
incoming.message_key,
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
NULL,
TRUE,
incoming.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages incoming
WHERE incoming.message_type IN (1, 3)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(owner_login),
tombstone.message_key,
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
NULL,
TRUE,
tombstone.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages tombstone
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
WHERE tombstone.message_type IN (5, 6, 7, 8)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 12, 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,46 @@
BEGIN;
-- Нормализация экспериментальной схемы доставки v12:
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 2;
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
-- передача безопасна благодаря messageKey и идемпотентному приёму.
UPDATE dm_delivery_state
SET delivery_expires_at_ms = created_at_ms + 3600000,
attempt_index = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
ELSE 1
END,
next_attempt_at_ms = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
THEN created_at_ms + 3600000
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
THEN created_at_ms + 1500000
ELSE created_at_ms + 30000
END,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 0;
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
CREATE INDEX idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 13, 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,620 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
WHEN 51 THEN 50
WHEN 53 THEN 52
WHEN 55 THEN 54
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
CREATE TRIGGER trg_blocks_connection_state_ai
AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_connection_state_ai();
TRUNCATE TABLE user_stats_state, channel_stats_state;
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
COALESCE(fu.following_users_count, 0) AS following_users_count,
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login);
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 14, 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,105 @@
BEGIN;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 15, 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, 9, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 13, 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;
@@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
id BIGSERIAL PRIMARY KEY,
tx_signature TEXT NOT NULL,
@@ -605,6 +611,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
ON channel_names_state (owner_login, owner_bch_name);
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
@@ -751,6 +783,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,
@@ -767,6 +840,51 @@ CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
-- Изменяемое состояние доставки исходящей пары. Подписанные блоки остаются
-- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4.
CREATE TABLE IF NOT EXISTS dm_delivery_state (
outgoing_message_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
base_key TEXT NOT NULL,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
incoming_message_key TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
delivery_expires_at_ms BIGINT NOT NULL,
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
delivered_server_login TEXT,
recipient_routes_hash TEXT,
attempt_index INTEGER NOT NULL DEFAULT 0,
next_attempt_at_ms BIGINT,
last_attempt_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
ON dm_delivery_state(base_key, from_login);
-- У одного пользователя теперь максимум один второй access-сервер, поэтому
-- достаточно одного флага ACK на событие. Курсор по времени больше не нужен.
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
owner_login TEXT NOT NULL,
primary_message_key TEXT NOT NULL,
event_id TEXT NOT NULL,
secondary_message_key TEXT,
synced BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, primary_message_key)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
WHERE synced = FALSE;
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -835,6 +953,132 @@ BEGIN
END;
$$;
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi()
RETURNS TRIGGER
LANGUAGE plpgsql
@@ -913,6 +1157,8 @@ AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
@@ -925,10 +1171,159 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login;
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
@@ -962,10 +1357,161 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login;
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
@@ -1145,4 +1691,10 @@ AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
COMMIT;
@@ -4,6 +4,8 @@ import org.eclipse.jetty.websocket.api.Session;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.ActiveSessionEntry;
import java.util.List;
/**
* ConnectionContext контекст состояния одного WebSocket-соединения.
* Живёт ровно столько же, сколько живёт подключение.
@@ -77,6 +79,12 @@ public class ConnectionContext {
*/
private Session wsSession;
/** Временная server-to-server роль, заявленная через ServerHello. */
private boolean serverConnection;
private String remoteServerLogin;
private int remoteServerProtocolVersion;
private List<String> remoteServerCapabilities = List.of();
// --- WebSocket Session ---
public Session getWsSession() {
@@ -87,6 +95,20 @@ public class ConnectionContext {
this.wsSession = wsSession;
}
public boolean isServerConnection() { return serverConnection; }
public void setServerConnection(boolean serverConnection) { this.serverConnection = serverConnection; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public int getRemoteServerProtocolVersion() { return remoteServerProtocolVersion; }
public void setRemoteServerProtocolVersion(int value) { this.remoteServerProtocolVersion = value; }
public List<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
public void setRemoteServerCapabilities(List<String> capabilities) {
this.remoteServerCapabilities = capabilities == null ? List.of() : List.copyOf(capabilities);
}
// --- SolanaUser / ActiveSession ---
public CurrentUserEntry getCurrentUser() {
@@ -188,6 +210,10 @@ public class ConnectionContext {
authenticationStatus = AUTH_STATUS_NONE;
wsSession = null;
serverConnection = false;
remoteServerLogin = null;
remoteServerProtocolVersion = 0;
remoteServerCapabilities = List.of();
}
@Override
@@ -98,6 +98,7 @@ 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_GetDmDeliveryStatus_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;
@@ -112,6 +113,7 @@ 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_GetDmDeliveryStatus_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;
@@ -131,6 +133,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ServerHello_Handler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
@@ -139,6 +142,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
import java.util.Map;
@@ -220,6 +224,7 @@ 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("GetDmDeliveryStatus", new Net_GetDmDeliveryStatus_Handler()),
Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()),
Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
@@ -229,6 +234,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", new Net_SendSignal_Handler()),
// --- system ---
Map.entry("ServerHello", new Net_ServerHello_Handler()),
Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
@@ -310,6 +316,7 @@ 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("GetDmDeliveryStatus", Net_GetDmDeliveryStatus_Request.class),
Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class),
Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
@@ -319,6 +326,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", Net_SendSignal_Request.class),
// --- system ---
Map.entry("ServerHello", Net_ServerHello_Request.class),
Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
@@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil;
import blockchain.body.CreateChannelBody;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
@@ -65,6 +67,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
channel.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion);
channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
@@ -180,4 +183,28 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private static int loadSubscribersCount(Connection c, String ownerBch, int rootNumber, int channelTypeCode) {
if (channelTypeCode != (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
return 0;
}
String sql = """
SELECT subscribers_count
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0;
return Math.max(0, rs.getInt("subscribers_count"));
}
} catch (Exception e) {
log.warn("GetChannelMessages: не удалось загрузить subscribers_count для {}#{}", ownerBch, rootNumber, e);
return 0;
}
}
}
@@ -32,6 +32,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private Integer subscribersCount;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
@@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public Integer getChannelTypeVersion() { return channelTypeVersion; }
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; }
public Integer getSubscribersCount() { return subscribersCount; }
public void setSubscribersCount(Integer subscribersCount) { this.subscribersCount = subscribersCount; }
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
@@ -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,70 @@
package server.logic.ws_protocol.JSON.handlers.system;
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.system.entyties.Net_ServerHello_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import utils.config.AppConfig;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
/**
* Временное server-to-server представление без криптографической проверки.
* Заявленный serverLogin принимается на доверии и привязывается к WS-контексту.
*/
public final class Net_ServerHello_Handler implements JsonMessageHandler {
private static final int PROTOCOL_VERSION = 1;
private static final List<String> CAPABILITIES = List.of(
"dm-sync", "settings-sync", "block-sync", "connection-pool");
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ServerHello_Request req = (Net_ServerHello_Request) baseRequest;
String remoteLogin = normalize(req.getServerLogin());
int remoteVersion = req.getProtocolVersion() == null ? 0 : req.getProtocolVersion();
if (ctx == null) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "NO_CONNECTION_CONTEXT", "ServerHello требует WebSocket-контекст");
}
if (remoteLogin == null || remoteVersion <= 0) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "BAD_SERVER_HELLO", "serverLogin/protocolVersion обязательны");
}
LinkedHashSet<String> unique = new LinkedHashSet<>();
if (req.getCapabilities() != null) {
for (String capability : req.getCapabilities()) {
String normalized = normalize(capability);
if (normalized != null && unique.size() < 64) unique.add(normalized);
}
}
ctx.setServerConnection(true);
ctx.setRemoteServerLogin(remoteLogin);
ctx.setRemoteServerProtocolVersion(remoteVersion);
ctx.setRemoteServerCapabilities(new ArrayList<>(unique));
Net_ServerHello_Response resp = new Net_ServerHello_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setAccepted(true);
String localLogin = normalize(AppConfig.getInstance().getParam("server.SHiNE.login"));
resp.setServerLogin(localLogin == null ? "" : localLogin);
resp.setProtocolVersion(PROTOCOL_VERSION);
resp.setCapabilities(CAPABILITIES);
return resp;
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
}
@@ -0,0 +1,20 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import java.util.List;
public final class Net_ServerHello_Request extends Net_Request {
private String serverLogin;
private Integer protocolVersion;
private List<String> capabilities;
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public Integer getProtocolVersion() { return protocolVersion; }
public void setProtocolVersion(Integer protocolVersion) { this.protocolVersion = protocolVersion; }
public List<String> getCapabilities() { return capabilities; }
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
}
@@ -0,0 +1,24 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.List;
public final class Net_ServerHello_Response extends Net_Response {
private boolean accepted;
private String serverLogin;
private int protocolVersion;
private List<String> capabilities;
public boolean isAccepted() { return accepted; }
public void setAccepted(boolean accepted) { this.accepted = accepted; }
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public int getProtocolVersion() { return protocolVersion; }
public void setProtocolVersion(int protocolVersion) { this.protocolVersion = protocolVersion; }
public List<String> getCapabilities() { return capabilities; }
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
}
@@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Re
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.CurrentUserEntry;
import java.sql.SQLException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Arrays;
public class Net_GetUser_Handler implements JsonMessageHandler {
@@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey());
resp.setClientKey(u.getClientKey());
loadUserStats(resp, u.getLogin());
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
// автоматически восстанавливаем её для существующего пользователя.
@@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
}
return new String(out);
}
private static void loadUserStats(Net_GetUser_Response resp, String login) {
resp.setOwnedPublicChannelsCount(0);
resp.setFollowingUsersCount(0);
resp.setFollowingChannelsCount(0);
resp.setCloseFriendsCount(0);
String sql = """
SELECT owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count
FROM user_stats_state
WHERE login = ?
LIMIT 1
""";
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return;
}
resp.setOwnedPublicChannelsCount(rs.getInt("owned_public_channels_count"));
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
}
} catch (Exception e) {
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
}
}
}
@@ -43,6 +43,10 @@ public class Net_GetUser_Response extends Net_Response {
private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes;
private Long serverBlockchainSizeLimitBytes;
private Integer ownedPublicChannelsCount;
private Integer followingUsersCount;
private Integer followingChannelsCount;
private Integer closeFriendsCount;
public Boolean getExists() { return exists; }
public void setExists(Boolean exists) { this.exists = exists; }
@@ -74,4 +78,16 @@ public class Net_GetUser_Response extends Net_Response {
public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; }
public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; }
public Integer getOwnedPublicChannelsCount() { return ownedPublicChannelsCount; }
public void setOwnedPublicChannelsCount(Integer ownedPublicChannelsCount) { this.ownedPublicChannelsCount = ownedPublicChannelsCount; }
public Integer getFollowingUsersCount() { return followingUsersCount; }
public void setFollowingUsersCount(Integer followingUsersCount) { this.followingUsersCount = followingUsersCount; }
public Integer getFollowingChannelsCount() { return followingChannelsCount; }
public void setFollowingChannelsCount(Integer followingChannelsCount) { this.followingChannelsCount = followingChannelsCount; }
public Integer getCloseFriendsCount() { return closeFriendsCount; }
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
}
@@ -74,10 +74,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
+ escapePart(valueText) + '|'
+ valueNum;
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
}
DbController db = DbController.getInstance();
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
@@ -95,6 +91,14 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
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,
@@ -119,7 +123,7 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
try {
REMOTE.upsertUserSetting(remoteUrl, entry, true);
REMOTE.upsertUserSetting(remoteLogin, remoteUrl, entry, true);
delivered++;
} catch (Exception e) {
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
@@ -0,0 +1,12 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.entities.SignedMessageEntry;
public final class DmDeliveryIds {
private DmDeliveryIds() {}
public static String forEntry(SignedMessageEntry entry) {
if (entry == null) throw new IllegalArgumentException("EMPTY_MESSAGE");
return entry.getMessageKey() + ":" + entry.getRevisionTimeMs() + ":" + entry.getReencryptedAtMs();
}
}
@@ -0,0 +1,25 @@
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.push.WsEventSender;
import shine.db.entities.DmDeliveryStateEntry;
public final class DmDeliveryRealtime {
private static final ObjectMapper MAPPER = new ObjectMapper();
private DmDeliveryRealtime() {}
public static void notifySender(DmDeliveryStateEntry state) {
if (state == null || state.getFromLogin() == null || state.getFromLogin().isBlank()) return;
ObjectNode payload = MAPPER.createObjectNode();
payload.put("baseKey", state.getBaseKey());
payload.put("outgoingKey", state.getOutgoingMessageKey());
payload.put("deliveryState", state.deliveryStateCode());
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(state.getFromLogin())) {
WsEventSender.sendEvent(ctx, "DmDeliveryStateChanged", state.getOutgoingMessageKey(), payload);
}
}
}
@@ -1,7 +1,13 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import server.sync.DmDeliveryCoordinator;
import java.util.List;
public final class DmSyncApplySupport {
private DmSyncApplySupport() {}
@@ -47,4 +53,56 @@ public final class DmSyncApplySupport {
int messageType,
SignedMessagesDAO.ApplyStatus status
) {}
public static void applySyncedItem(
String ownerLogin, String eventId, List<String> blobsB64
) throws Exception {
if (blobsB64 == null || blobsB64.isEmpty() || blobsB64.size() > 2) {
throw new IllegalArgumentException("BAD_BLOB_COUNT");
}
if (blobsB64.size() == 1) {
ApplyResult result = applySyncedBlob(ownerLogin, blobsB64.get(0));
SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(result.messageKey());
long createdAt = stored == null ? System.currentTimeMillis() : stored.getCreatedAtMs();
DmSyncOutboxDAO.getInstance().upsert(
ownerLogin, result.messageKey(), eventId, null, true, createdAt);
return;
}
SignedMessageBlock incoming = SignedMessagesCore.parseFromB64(blobsB64.get(0));
SignedMessageBlock outgoing = SignedMessagesCore.parseFromB64(blobsB64.get(1));
SignedMessagesCore.validatePair(incoming, outgoing);
SignedMessagesCore.verifyUsersAndSignature(incoming);
SignedMessagesCore.verifyUsersAndSignature(outgoing);
if (!outgoing.fromLogin.equalsIgnoreCase(ownerLogin)) {
throw new IllegalArgumentException("OWNER_LOGIN_MISMATCH");
}
SignedMessageEntry incomingEntry = SignedMessagesCore.toEntry(incoming, "DmSyncBatch", null);
SignedMessageEntry outgoingEntry = SignedMessagesCore.toEntry(outgoing, "DmSyncBatch", null);
if (incoming.isContentType()) {
SignedMessagesDAO.getInstance().upsertContentPair(incomingEntry, outgoingEntry);
} else {
SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
}
DmSyncOutboxDAO.getInstance().upsert(
ownerLogin, outgoingEntry.getMessageKey(), eventId,
incomingEntry.getMessageKey(), true, outgoingEntry.getCreatedAtMs());
long now = System.currentTimeMillis();
long signedAt = Math.max(outgoing.timeMs,
Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs));
long acceptedAt = signedAt > 0L ? Math.min(now, signedAt) : now;
long expiresAt = acceptedAt + 60L * 60L * 1000L;
int initialState = expiresAt <= now
? DmDeliveryStateEntry.FAILED_FINAL
: DmDeliveryStateEntry.PENDING_NONE;
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
acceptedAt, expiresAt, initialState, null, null,
initialState == DmDeliveryStateEntry.PENDING_NONE);
if (delivery != null && initialState == DmDeliveryStateEntry.PENDING_NONE) {
DmDeliveryCoordinator.assistReceivedPairAsync(eventId);
}
}
}
@@ -10,6 +10,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmFederationService;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.entities.SignedMessageEntry;
public class Net_DeleteConversation_Handler implements JsonMessageHandler {
@@ -43,6 +45,8 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
DmDeliveryStateDAO.getInstance().removeMissingMessages();
recordOutboxForLocalOwners(entry);
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
}
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
@@ -63,4 +67,15 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception {
String eventId = DmDeliveryIds.forEntry(entry);
if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin())
&& server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
}
}
@@ -10,6 +10,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmFederationService;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.entities.SignedMessageEntry;
public class Net_DeleteMessage_Handler implements JsonMessageHandler {
@@ -43,6 +45,8 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey());
recordOutboxForLocalOwners(entry);
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
}
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
@@ -63,4 +67,15 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception {
String eventId = DmDeliveryIds.forEntry(entry);
if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin())
&& server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
}
}
@@ -8,8 +8,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncOutboxEntry;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
@@ -18,6 +20,7 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/** Pull синхронизация только событий synced=false с ACK предыдущей страницы. */
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private static final int DEFAULT_LIMIT = 500;
private static final int MAX_LIMIT = 500;
@@ -46,13 +49,14 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
ownerLogin,
afterStoredAtMs,
afterMessageKey,
limit,
maxBytes
);
if (req.getAckSyncIds() != null && req.getAckSyncIds().size() > MAX_LIMIT) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
"TOO_MANY_ACKS", "ackSyncIds содержит слишком много элементов");
}
DmSyncOutboxDAO outbox = DmSyncOutboxDAO.getInstance();
outbox.markSynced(ownerLogin, req.getAckSyncIds());
List<DmSyncOutboxEntry> batch = outbox.listUnsynced(
ownerLogin, afterStoredAtMs, afterMessageKey, limit);
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
resp.setOp(req.getOp());
@@ -60,28 +64,42 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
resp.setStatus(WireCodes.Status.OK);
resp.setOwnerLogin(ownerLogin);
resp.setLimit(limit);
resp.setRawBytes(batch.rawBytes());
resp.setHasMore(batch.hasMore());
resp.setRawBytes(0);
resp.setHasMore(false);
resp.setNextStoredAtMs(afterStoredAtMs);
resp.setNextMessageKey(afterMessageKey);
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
Base64.Encoder encoder = Base64.getEncoder();
for (SignedMessageEntry entry : batch.items()) {
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setMessageKey(entry.getMessageKey());
item.setBaseKey(entry.getBaseKey());
item.setTargetLogin(entry.getTargetLogin());
item.setFromLogin(entry.getFromLogin());
item.setToLogin(entry.getToLogin());
item.setMessageType(entry.getMessageType());
item.setTimeMs(entry.getTimeMs());
item.setStoredAtMs(entry.getCreatedAtMs());
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
items.add(item);
resp.setNextStoredAtMs(entry.getCreatedAtMs());
resp.setNextMessageKey(entry.getMessageKey());
int rawBytes = 0;
for (DmSyncOutboxEntry row : batch) {
SignedMessageEntry primary = SignedMessagesDAO.getInstance().getByMessageKey(row.getPrimaryMessageKey());
if (primary == null || primary.getRawBlock() == null) continue;
SignedMessageEntry secondary = null;
if (row.getSecondaryMessageKey() != null && !row.getSecondaryMessageKey().isBlank()) {
secondary = SignedMessagesDAO.getInstance().getByMessageKey(row.getSecondaryMessageKey());
if (secondary == null || secondary.getRawBlock() == null) continue;
}
int itemBytes = primary.getRawBlock().length + (secondary == null ? 0 : secondary.getRawBlock().length);
if (!items.isEmpty() && rawBytes + itemBytes > maxBytes) {
resp.setHasMore(true);
break;
}
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setSyncId(row.getEventId());
item.setPrimaryMessageKey(row.getPrimaryMessageKey());
item.setStoredAtMs(row.getCreatedAtMs());
List<String> blobs = new ArrayList<>();
if (secondary != null) blobs.add(encoder.encodeToString(secondary.getRawBlock()));
blobs.add(encoder.encodeToString(primary.getRawBlock()));
item.setBlobsB64(blobs);
items.add(item);
rawBytes += itemBytes;
resp.setNextStoredAtMs(row.getCreatedAtMs());
resp.setNextMessageKey(row.getPrimaryMessageKey());
}
if (!resp.isHasMore() && batch.size() >= limit) resp.setHasMore(true);
resp.setRawBytes(rawBytes);
resp.setItems(items);
return resp;
}
@@ -89,9 +107,7 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
if (route == null || route.getServerLogin() == null) continue;
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
return true;
}
if (ownServerLogin.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
@@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class);
@@ -55,6 +59,11 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
if (hasMore) {
page = new ArrayList<>(page.subList(0, limit));
}
Map<String, DmDeliveryStateEntry> deliveryByKey = DmDeliveryStateDAO.getInstance()
.listByOutgoingMessageKeys(page.stream()
.filter(entry -> entry.getMessageType() == SignedMessageBlock.TYPE_OUTGOING_COPY)
.map(SignedMessageEntry::getMessageKey)
.collect(Collectors.toList()));
Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response();
resp.setOp(req.getOp());
@@ -80,6 +89,10 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
item.setCreatedAtMs(entry.getCreatedAtMs());
item.setReadAtMs(entry.getReadAtMs());
item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock()));
DmDeliveryStateEntry delivery = deliveryByKey.get(entry.getMessageKey());
if (delivery != null) {
item.setDeliveryState(delivery.deliveryStateCode());
}
items.add(item);
}
resp.setMessages(items);
@@ -0,0 +1,35 @@
package server.logic.ws_protocol.JSON.messages;
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_GetDmDeliveryStatus_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDmDeliveryStatus_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
/** Read-only проверка: доставил ли peer хотя бы одну копию получателю. */
public class Net_GetDmDeliveryStatus_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetDmDeliveryStatus_Request req = (Net_GetDmDeliveryStatus_Request) baseRequest;
if (req.getMessageKey() == null || req.getMessageKey().isBlank()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "EMPTY_MESSAGE_KEY", "messageKey обязателен");
}
String messageKey = req.getMessageKey().trim();
DmDeliveryStateEntry state = DmDeliveryStateDAO.getInstance().getByOutgoingMessageKey(messageKey);
Net_GetDmDeliveryStatus_Response resp = new Net_GetDmDeliveryStatus_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageKey(messageKey);
resp.setKnown(state != null);
resp.setDelivered(state != null && state.isDelivered());
return resp;
}
}
@@ -12,6 +12,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.UserSettingsDAO;
import shine.db.dao.DmSyncOutboxDAO;
import server.sync.DmSyncWakeSignal;
import java.sql.Connection;
@@ -23,16 +25,21 @@ public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandl
Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest;
try (Connection c = DbController.getInstance().getConnection()) {
int updated;
int dmUpdated;
if (req.getLogin() == null || req.getLogin().isBlank()) {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c);
dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced();
} else {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim());
dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(req.getLogin().trim());
}
DmSyncWakeSignal.request();
Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setUpdated(updated);
resp.setDmUpdated(dmUpdated);
return resp;
} catch (Exception e) {
log.error("MarkAllUserSettingsUnsynced failed", e);
@@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmDeliveryCoordinator;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.SignedMessageEntry;
import java.util.Arrays;
import java.util.Base64;
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
@@ -40,6 +43,10 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
}
if (!DmDeliveryCoordinator.isLocalAccessServer(incoming.toLogin)) {
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Сервер не обслуживает получателя сообщения");
}
final SignedMessageEntry entry;
try {
entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null);
@@ -53,16 +60,27 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
incoming.toLogin,
req.getIncomingBlobB64().trim(),
req.getSourceServerLogin()
);
}
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией");
}
SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(entry.getMessageKey());
if (stored == null || !Arrays.equals(stored.getRawBlock(), entry.getRawBlock())) {
return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения");
}
boolean receivedFromRecipientPeer = DmDeliveryCoordinator.sourceIsOtherAccessServer(
incoming.toLogin, req.getSourceServerLogin());
DmSyncOutboxDAO.getInstance().upsert(
incoming.toLogin,
entry.getMessageKey(),
DmDeliveryIds.forEntry(entry),
null,
receivedFromRecipientPeer,
entry.getCreatedAtMs()
);
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
@@ -8,10 +8,15 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmDeliveryCoordinator;
import server.sync.DmFederationService;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import java.util.Arrays;
import java.util.Base64;
public class Net_SendMessagePair_Handler implements JsonMessageHandler {
@@ -43,8 +48,9 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
SignedMessageEntry incomingEntry;
SignedMessageEntry outgoingEntry;
boolean fromPeer = !isBlank(req.getSourceServerLogin());
try {
String sourceApi = "SendMessagePair";
String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair";
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
@@ -79,15 +85,53 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией");
}
if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) {
DmFederationService.fanOutPair(
incoming.fromLogin,
incoming.toLogin,
req.getIncomingBlobB64().trim(),
req.getOutgoingBlobB64().trim()
SignedMessageEntry storedOutgoing = SignedMessagesDAO.getInstance().getByMessageKey(outgoingEntry.getMessageKey());
if (storedOutgoing == null || !Arrays.equals(storedOutgoing.getRawBlock(), outgoingEntry.getRawBlock())) {
return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения");
}
String eventId = DmDeliveryIds.forEntry(outgoingEntry);
long nowMs = System.currentTimeMillis();
long acceptedAtMs;
long signedAtMs = Math.max(outgoing.timeMs,
Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs));
acceptedAtMs = fromPeer && signedAtMs > 0L ? Math.min(nowMs, signedAtMs) : nowMs;
long expiresAtMs = acceptedAtMs + 60L * 60L * 1000L;
int initialState = DmDeliveryStateEntry.ACCEPTED;
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
acceptedAtMs, expiresAtMs, initialState,
null, null, fromPeer && initialState == DmDeliveryStateEntry.PENDING_NONE
);
DmSyncOutboxDAO.getInstance().upsert(
outgoingEntry.getFromLogin(), outgoingEntry.getMessageKey(), eventId,
incomingEntry.getMessageKey(), fromPeer, acceptedAtMs);
// Если этот же сервер также обслуживает получателя, его входящая копия
// имеет отдельный sync-флаг владельца-получателя.
if (DmDeliveryCoordinator.isLocalAccessServer(incomingEntry.getToLogin())) {
boolean incomingAlreadySynced = DmDeliveryCoordinator.sourceIsOtherAccessServer(
incomingEntry.getToLogin(), req.getSourceServerLogin());
DmSyncOutboxDAO.getInstance().upsert(
incomingEntry.getToLogin(), incomingEntry.getMessageKey(), DmDeliveryIds.forEntry(incomingEntry),
null, incomingAlreadySynced, acceptedAtMs);
}
if (delivery != null && pairStatus.applied()) {
if (fromPeer) {
DmDeliveryCoordinator.assistReceivedPairAsync(delivery.getEventId());
} else {
// Первая доставка выполняется до ответа клиенту. Два сервера
// получателя вызываются параллельно внутри координатора.
DmDeliveryCoordinator.processDueEntry(delivery);
delivery = DmDeliveryStateDAO.getInstance()
.getByOutgoingMessageKey(outgoingEntry.getMessageKey());
}
}
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
@@ -99,6 +143,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
resp.setOutgoingKey(outgoingEntry.getMessageKey());
resp.setDeliveredWsSessions(inCounters.wsDelivered + outCounters.wsDelivered);
resp.setDeliveredWebPushSessions(inCounters.pushDelivered + outCounters.pushDelivered);
resp.setDeliveryState(delivery.deliveryStateCode());
return resp;
}
@@ -2,12 +2,16 @@ package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import java.util.ArrayList;
import java.util.List;
public class Net_DmSyncBatch_Request extends Net_Request {
private String ownerLogin;
private Long afterStoredAtMs;
private String afterMessageKey;
private Integer limit;
private Integer maxBytes;
private List<String> ackSyncIds = new ArrayList<>();
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
@@ -19,4 +23,8 @@ public class Net_DmSyncBatch_Request extends Net_Request {
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getMaxBytes() { return maxBytes; }
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
public List<String> getAckSyncIds() { return ackSyncIds; }
public void setAckSyncIds(List<String> ackSyncIds) {
this.ackSyncIds = ackSyncIds == null ? new ArrayList<>() : ackSyncIds;
}
}
@@ -30,33 +30,20 @@ public class Net_DmSyncBatch_Response extends Net_Response {
public void setItems(List<Item> items) { this.items = items; }
public static class Item {
private String messageKey;
private String baseKey;
private String targetLogin;
private String fromLogin;
private String toLogin;
private int messageType;
private long timeMs;
private String syncId;
private String primaryMessageKey;
private long storedAtMs;
private String blobB64;
private List<String> blobsB64 = new ArrayList<>();
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
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 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 String getSyncId() { return syncId; }
public void setSyncId(String syncId) { this.syncId = syncId; }
public String getPrimaryMessageKey() { return primaryMessageKey; }
public void setPrimaryMessageKey(String primaryMessageKey) { this.primaryMessageKey = primaryMessageKey; }
public long getStoredAtMs() { return storedAtMs; }
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
public List<String> getBlobsB64() { return blobsB64; }
public void setBlobsB64(List<String> blobsB64) {
this.blobsB64 = blobsB64 == null ? new ArrayList<>() : blobsB64;
}
}
}
@@ -42,6 +42,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
private long createdAtMs;
private Long readAtMs;
private String blobB64;
private String deliveryState;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
@@ -67,5 +68,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
public String getDeliveryState() { return deliveryState; }
public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; }
}
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetDmDeliveryStatus_Request extends Net_Request {
private String messageKey;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String value) { this.messageKey = value; }
}
@@ -0,0 +1,16 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_GetDmDeliveryStatus_Response extends Net_Response {
private String messageKey;
private boolean known;
private boolean delivered;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String value) { this.messageKey = value; }
public boolean isKnown() { return known; }
public void setKnown(boolean value) { this.known = value; }
public boolean isDelivered() { return delivered; }
public void setDelivered(boolean value) { this.delivered = value; }
}
@@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response {
private Integer updated;
private Integer dmUpdated;
public Integer getUpdated() { return updated; }
public void setUpdated(Integer updated) { this.updated = updated; }
public Integer getDmUpdated() { return dmUpdated; }
public void setDmUpdated(Integer dmUpdated) { this.dmUpdated = dmUpdated; }
}
@@ -8,6 +8,7 @@ public class Net_SendMessagePair_Response extends Net_Response {
private String outgoingKey;
private int deliveredWsSessions;
private int deliveredWebPushSessions;
private String deliveryState;
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
@@ -19,4 +20,6 @@ public class Net_SendMessagePair_Response extends Net_Response {
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
public String getDeliveryState() { return deliveryState; }
public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; }
}
@@ -11,27 +11,19 @@ import shine.db.entities.BlockEntry;
import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
* Фоновая репликация AddBlock через общий постоянный WSS-пул на серверы
* из локальной таблицы sync_servers.
*/
public final class AddBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
1,
Math.max(2, Runtime.getRuntime().availableProcessors()),
@@ -100,14 +92,14 @@ public final class AddBlockSyncService {
}
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
String wsUrl = buildWsUrl(partner.getServerAddress());
String wsUrl = ServerConnectionPool.buildWsUrl(partner.getServerAddress());
if (wsUrl == null) {
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
partner.getLogin(), partner.getServerAddress());
return;
}
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
AddBlockPushResult firstTry = pushBlock(partner, blockchainName, currentBlock);
if (firstTry.ok()) {
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
partner.getLogin(), blockchainName, blockNumber);
@@ -142,7 +134,7 @@ public final class AddBlockSyncService {
}
for (BlockEntry blockEntry : missingBlocks) {
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
AddBlockPushResult backfillResult = pushBlock(partner, blockchainName, blockEntry);
if (!backfillResult.ok()) {
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
@@ -154,8 +146,8 @@ public final class AddBlockSyncService {
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
}
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
private AddBlockPushResult pushBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(partner, blockchainName, blockEntry);
int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) {
return AddBlockPushResult.success();
@@ -172,31 +164,16 @@ public final class AddBlockSyncService {
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
}
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
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");
private JsonNode sendAddBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
String jsonTemplate = buildAddBlockJsonTemplate(blockchainName, blockEntry);
return ServerConnectionPool.getInstance().request(
partner.getLogin(),
partner.getServerAddress(),
jsonTemplate,
ServerConnectionPool.Priority.BULK);
}
String requestId = "sync-" + UUID.randomUUID();
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
}
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
private String buildAddBlockJsonTemplate(String blockchainName, BlockEntry blockEntry) throws Exception {
String prevHashHex = blockEntry.getBlockNumber() <= 0
? ""
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
@@ -205,8 +182,6 @@ public final class AddBlockSyncService {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
String safeRequestId = MAPPER.writeValueAsString(requestId);
return """
{
"op":"AddBlock",
@@ -218,7 +193,7 @@ public final class AddBlockSyncService {
"blockBytesB64":%s
}
}
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
""".formatted("%s", safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
}
private static byte[] extractPrevHash32(byte[] blockBytes) {
@@ -240,32 +215,6 @@ public final class AddBlockSyncService {
return s.isEmpty() ? null : s;
}
private static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "";
StringBuilder sb = new StringBuilder(bytes.length * 2);
@@ -276,17 +225,6 @@ public final class AddBlockSyncService {
return sb.toString();
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
private record AddBlockPushResult(
boolean ok,
int status,
@@ -309,52 +247,4 @@ public final class AddBlockSyncService {
}
}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
}
@@ -0,0 +1,311 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmDeliveryRealtime;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadFactory;
/**
* Координатор доставки исходящей DM-пары. Первая попытка выполняется до ответа
* клиенту, последующие пятисекундным воркером.
*/
public final class DmDeliveryCoordinator {
private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class);
private static final long[] ATTEMPT_OFFSETS_MS = {
0L,
30_000L,
5L * 60_000L,
25L * 60_000L,
60L * 60_000L
};
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance();
private static final DmSyncOutboxDAO OUTBOX_DAO = DmSyncOutboxDAO.getInstance();
private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final ExecutorService ASSIST_EXECUTOR = new ThreadPoolExecutor(
2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500),
daemonThreadFactory("dm-peer-assist"),
new ThreadPoolExecutor.DiscardPolicy());
private static final ExecutorService RECIPIENT_EXECUTOR = new ThreadPoolExecutor(
4, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
daemonThreadFactory("dm-recipient-delivery"),
new ThreadPoolExecutor.CallerRunsPolicy());
private DmDeliveryCoordinator() {}
public static List<DmDeliveryStateEntry> listDue(int limit) throws Exception {
return DELIVERY_DAO.listDue(System.currentTimeMillis(), Math.max(1, limit));
}
public static void processDueEntry(DmDeliveryStateEntry snapshot) {
processDueEntry(snapshot, true);
}
private static void processDueEntry(DmDeliveryStateEntry snapshot, boolean allowInitialHandoff) {
if (snapshot == null) return;
try {
long now = System.currentTimeMillis();
Long nextAttemptAtMs = nextAttemptAt(snapshot.getCreatedAtMs(), snapshot.getAttemptIndex() + 1);
if (!DELIVERY_DAO.claimAttempt(snapshot, now, nextAttemptAtMs)) return;
DmDeliveryStateEntry current = DELIVERY_DAO.getByEventId(snapshot.getEventId());
if (current == null || current.isDelivered()
|| current.getDeliveryState() == DmDeliveryStateEntry.FAILED) return;
boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1
|| now >= current.getDeliveryExpiresAtMs();
// Перед попытками на 5-й, 25-й и 60-й минутах сначала спрашиваем
// второй сервер отправителя: возможно, он уже доставил сообщение.
if (current.getDeliveryState() == DmDeliveryStateEntry.ACCEPTED
&& (snapshot.getAttemptIndex() >= 2 || finalAttempt)) {
current = acceptPeerDeliveryStatus(current);
if (current != null && current.isDelivered()) {
DmDeliveryRealtime.notifySender(current);
return;
}
}
DmDeliveryStateEntry afterAttempt = attemptRecipientRoutes(
current, finalAttempt ? null : nextAttemptAtMs);
// После первой попытки сразу передаём полную пару второму серверу
// отправителя старой операцией ReceiveOutcomingMessage.
if (allowInitialHandoff && snapshot.getAttemptIndex() == 0 && afterAttempt != null) {
afterAttempt = handoffPairToSenderPeer(afterAttempt);
}
if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) {
afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis());
}
DmDeliveryRealtime.notifySender(afterAttempt);
} catch (Exception e) {
log.warn("DM delivery attempt failed: eventId={}", snapshot.getEventId(), e);
}
}
public static void assistReceivedPairAsync(String eventId) {
if (eventId == null || eventId.isBlank()) return;
ASSIST_EXECUTOR.execute(() -> {
try {
DmDeliveryStateEntry row = DELIVERY_DAO.getByEventId(eventId);
if (row != null) processDueEntry(row, false);
} catch (Exception e) {
log.warn("DM peer assist failed: eventId={}", eventId, e);
}
});
}
private static DmDeliveryStateEntry attemptRecipientRoutes(
DmDeliveryStateEntry current,
Long nextAttemptAtMs
) throws Exception {
if (current == null) return null;
List<UserAccessServerRouteEntry> routes = cappedRoutes(current.getToLogin());
String routesHash = routesHash(routes);
String alreadyDelivered = normalize(current.getDeliveredServerLogin());
List<String> acceptedLogins = new ArrayList<>();
if (alreadyDelivered != null) acceptedLogins.add(alreadyDelivered);
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
if (incoming == null || incoming.getRawBlock() == null) {
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(),
routesHash, nextAttemptAtMs, "INCOMING_BLOB_NOT_FOUND", System.currentTimeMillis());
}
String incomingBlobB64 = Base64.getEncoder().encodeToString(incoming.getRawBlock());
String ownServerLogin = ownServerLogin();
java.util.concurrent.CompletionService<RouteAttempt> completion =
new java.util.concurrent.ExecutorCompletionService<>(RECIPIENT_EXECUTOR);
int submitted = 0;
for (UserAccessServerRouteEntry route : routes) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin == null || acceptedLogins.contains(routeLogin)) continue;
completion.submit(() -> {
try {
if (!routeLogin.equals(ownServerLogin)) {
REMOTE.receiveIncomingMessage(
routeLogin, route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
return new RouteAttempt(routeLogin, null);
} catch (Exception e) {
log.info("DM recipient server unavailable: messageKey={} server={}",
current.getOutgoingMessageKey(), routeLogin);
return new RouteAttempt(routeLogin, compactError(e));
}
});
submitted++;
}
String lastError = null;
for (int i = 0; i < submitted && acceptedLogins.isEmpty(); i++) {
RouteAttempt result = completion.take().get();
if (result.error() == null) {
acceptedLogins.add(result.serverLogin());
} else {
lastError = result.error();
}
}
int nextState = acceptedLogins.isEmpty()
? DmDeliveryStateEntry.ACCEPTED
: DmDeliveryStateEntry.DELIVERED;
String oneLogin = acceptedLogins.isEmpty() ? null : acceptedLogins.get(0);
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), nextState, oneLogin, routesHash,
nextAttemptAtMs, lastError, System.currentTimeMillis());
}
private static DmDeliveryStateEntry handoffPairToSenderPeer(DmDeliveryStateEntry current) throws Exception {
UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin());
if (peer == null) return current;
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
SignedMessageEntry outgoing = MESSAGES_DAO.getByMessageKey(current.getOutgoingMessageKey());
if (incoming == null || outgoing == null) return current;
try {
REMOTE.sendMessagePair(
peer.getServerLogin(),
peer.getServerUrl(),
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
ownServerLogin());
OUTBOX_DAO.markSynced(current.getFromLogin(), current.getEventId());
} catch (Exception e) {
log.info("DM sender peer unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
}
return current;
}
private static DmDeliveryStateEntry acceptPeerDeliveryStatus(DmDeliveryStateEntry current) throws Exception {
UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin());
if (peer == null) return current;
try {
RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus(
peer.getServerLogin(), peer.getServerUrl(), current.getOutgoingMessageKey());
if (remote.known() && remote.delivered()) {
return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis());
}
return current;
} catch (Exception e) {
log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
return current;
}
}
private static UserAccessServerRouteEntry senderPeer(String senderLogin) throws Exception {
String own = ownServerLogin();
for (UserAccessServerRouteEntry route : cappedRoutes(senderLogin)) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin != null && !routeLogin.equals(own)) return route;
}
return null;
}
public static boolean isLocalAccessServer(String ownerLogin) throws Exception {
String own = ownServerLogin();
if (own == null) return false;
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
if (own.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
public static boolean sourceIsOtherAccessServer(String ownerLogin, String sourceServerLogin) throws Exception {
String source = normalize(sourceServerLogin);
String own = ownServerLogin();
if (source == null || source.equals(own)) return false;
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
if (source.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
public static String currentServerLogin() {
return ownServerLogin();
}
private static List<UserAccessServerRouteEntry> cappedRoutes(String ownerLogin) throws Exception {
Map<String, UserAccessServerRouteEntry> unique = new LinkedHashMap<>();
for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) {
if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue;
String login = normalize(route.getServerLogin());
if (login == null) continue;
unique.putIfAbsent(login, route);
if (unique.size() == 2) break;
}
return new ArrayList<>(unique.values());
}
private static String routesHash(List<UserAccessServerRouteEntry> routes) throws Exception {
List<String> logins = new ArrayList<>();
for (UserAccessServerRouteEntry route : routes) {
String login = normalize(route.getServerLogin());
if (login != null) logins.add(login);
}
logins.sort(String::compareTo);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(String.join("\n", logins).getBytes(StandardCharsets.UTF_8));
StringBuilder out = new StringBuilder(hash.length * 2);
for (byte b : hash) out.append(String.format("%02x", b & 0xff));
return out.toString();
}
private static Long nextAttemptAt(long createdAtMs, int nextAttemptIndex) {
if (nextAttemptIndex < 0 || nextAttemptIndex >= ATTEMPT_OFFSETS_MS.length) return null;
return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex];
}
private record RouteAttempt(String serverLogin, String error) {}
private static String ownServerLogin() {
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static String compactError(Exception e) {
String text = String.valueOf(e == null ? "unknown" : e.getMessage());
return text.length() <= 500 ? text : text.substring(0, 500);
}
private static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private int sequence;
@Override
public synchronized Thread newThread(Runnable r) {
Thread thread = new Thread(r, prefix + "-" + (++sequence));
thread.setDaemon(true);
return thread;
}
};
}
}
@@ -27,12 +27,15 @@ public final class DmFederationService {
String ownServerLogin = ownServerLogin();
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin);
REMOTE.sendMessagePair(
route.getServerLogin(), route.getServerUrl(),
incomingBlobB64, outgoingBlobB64, ownServerLogin);
}
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
REMOTE.receiveIncomingMessage(
route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
@@ -54,7 +57,8 @@ public final class DmFederationService {
if (routeLogin == null) continue;
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
REMOTE.receiveIncomingMessage(
route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
@@ -79,9 +83,9 @@ public final class DmFederationService {
for (UserAccessServerRouteEntry route : routes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (oneMessageDelete) {
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
REMOTE.deleteMessage(route.getServerLogin(), route.getServerUrl(), blobB64);
} else {
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
REMOTE.deleteConversation(route.getServerLogin(), route.getServerUrl(), blobB64);
}
}
} catch (Exception e) {
@@ -0,0 +1,17 @@
package server.sync;
import java.util.concurrent.atomic.AtomicBoolean;
public final class DmSyncWakeSignal {
private static final AtomicBoolean REQUESTED = new AtomicBoolean(false);
private DmSyncWakeSignal() {}
public static void request() {
REQUESTED.set(true);
}
public static boolean consume() {
return REQUESTED.getAndSet(false);
}
}
@@ -2,37 +2,22 @@ package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
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;
/**
* Минимальный клиент для межсерверных JSON-op запросов по WSS.
*/
public final class RemoteBlockchainSyncClient {
private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public List<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception {
JsonNode response = send(serverAddressRaw, """
return listBlockchainHeads(null, serverAddressRaw);
}
public List<RemoteBlockchainHead> listBlockchainHeads(String serverLogin, String serverAddressRaw) throws Exception {
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"ListBlockchainHeads",
"requestId":%s,
@@ -62,8 +47,12 @@ public final class RemoteBlockchainSyncClient {
}
public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception {
return getSyncUserProfile(null, serverAddressRaw, login);
}
public RemoteSyncUserProfile getSyncUserProfile(String serverLogin, String serverAddressRaw, String login) throws Exception {
String safeLogin = MAPPER.writeValueAsString(login);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"GetSyncUserProfile",
"requestId":%s,
@@ -96,9 +85,17 @@ public final class RemoteBlockchainSyncClient {
);
}
public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception {
public RemoteBlockchainBlock getBlockchainBlock(
String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
return getBlockchainBlock(null, serverAddressRaw, blockchainName, blockNumber);
}
public RemoteBlockchainBlock getBlockchainBlock(
String serverLogin, String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"GetBlockchainBlock",
"requestId":%s,
@@ -126,32 +123,12 @@ public final class RemoteBlockchainSyncClient {
);
}
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID());
String json = jsonTemplate.formatted(requestId);
String wsUrl = 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 JsonNode send(String serverLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
serverLogin,
serverAddressRaw,
jsonTemplate,
ServerConnectionPool.Priority.BULK);
}
private static String errorCode(JsonNode response) {
@@ -161,40 +138,7 @@ public final class RemoteBlockchainSyncClient {
}
static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
return ServerConnectionPool.buildWsUrl(serverAddressRaw);
}
public record RemoteBlockchainHead(
@@ -220,53 +164,4 @@ public final class RemoteBlockchainSyncClient {
long blockchainSizeLimitBytes
) {}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
log.warn("Remote sync websocket error: {}", String.valueOf(error));
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
}
@@ -3,31 +3,33 @@ package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
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;
/** Клиент стабильных межсерверных DM-операций. */
public final class RemoteDmSyncClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
public void sendMessagePair(
String serverAddressRaw,
String incomingBlobB64,
String outgoingBlobB64,
String sourceServerLogin
) throws Exception {
sendMessagePair(null, serverAddressRaw, incomingBlobB64, outgoingBlobB64, sourceServerLogin);
}
public void sendMessagePair(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String outgoingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"ReceiveOutcomingMessage",
"requestId":%s,
@@ -40,10 +42,23 @@ public final class RemoteDmSyncClient {
ensureOk("ReceiveOutcomingMessage", response);
}
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
public void receiveIncomingMessage(
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin);
}
public void receiveIncomingMessage(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"ReceiveIncomingMessage",
"requestId":%s,
@@ -55,17 +70,74 @@ public final class RemoteDmSyncClient {
ensureOk("ReceiveIncomingMessage", response);
}
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
return getDmDeliveryStatus(null, serverAddressRaw, messageKey);
}
public RemoteDeliveryStatus getDmDeliveryStatus(
String targetServerLogin, String serverAddressRaw, String messageKey
) throws Exception {
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"GetDmDeliveryStatus",
"requestId":%s,
"payload":{
"messageKey":%s
}
}
""".formatted("%s", messageKeyJson));
ensureOk("GetDmDeliveryStatus", response);
JsonNode payload = response.path("payload");
return new RemoteDeliveryStatus(
payload.path("messageKey").asText(messageKey),
payload.path("known").asBoolean(false),
payload.path("delivered").asBoolean(false)
);
}
public RemoteDmBatch dmSyncBatch(
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
int maxBytes,
List<String> ackSyncIds
) throws Exception {
return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs,
afterMessageKey, limit, maxBytes, ackSyncIds);
}
public RemoteDmBatch dmSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes,
List<String> ackSyncIds
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
limit, maxBytes, ackSyncIds);
}
}
public RemoteDmBatch dmSyncBatch(
RemoteSyncSession session,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes,
List<String> ackSyncIds
) throws Exception {
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
JsonNode response = send(serverAddressRaw, """
String ackSyncIdsJson = MAPPER.writeValueAsString(ackSyncIds == null ? List.of() : ackSyncIds);
JsonNode response = session.send("""
{
"op":"DmSyncBatch",
"requestId":%s,
@@ -74,10 +146,12 @@ public final class RemoteDmSyncClient {
"afterStoredAtMs":%d,
"afterMessageKey":%s,
"limit":%d,
"maxBytes":%d
"maxBytes":%d,
"ackSyncIds":%s
}
}
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson,
limit, maxBytes, ackSyncIdsJson));
ensureOk("DmSyncBatch", response);
JsonNode payload = response.path("payload");
@@ -85,11 +159,17 @@ public final class RemoteDmSyncClient {
JsonNode arr = payload.path("items");
if (arr.isArray()) {
for (JsonNode item : arr) {
List<String> blobs = new ArrayList<>();
JsonNode blobsNode = item.path("blobsB64");
if (blobsNode.isArray()) for (JsonNode blob : blobsNode) blobs.add(blob.asText(""));
if (blobs.isEmpty() && !item.path("blobB64").asText("").isBlank()) {
blobs.add(item.path("blobB64").asText(""));
}
items.add(new RemoteDmItem(
item.path("messageKey").asText(""),
item.path("syncId").asText(""),
item.path("primaryMessageKey").asText(item.path("messageKey").asText("")),
item.path("storedAtMs").asLong(0L),
item.path("blobB64").asText("")
));
blobs));
}
}
return new RemoteDmBatch(
@@ -101,65 +181,47 @@ public final class RemoteDmSyncClient {
}
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
deleteMessage(null, serverAddressRaw, blobB64);
}
public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteMessage",
"requestId":%s,
"payload":{
"blobB64":%s
}
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteMessage", response);
}
public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception {
deleteConversation(null, serverAddressRaw, blobB64);
}
public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteConversation",
"requestId":%s,
"payload":{
"blobB64":%s
}
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteConversation", response);
}
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("dm-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 JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
targetServerLogin,
serverAddressRaw,
jsonTemplate,
ServerConnectionPool.Priority.REALTIME);
}
private String toOptionalJsonField(String fieldName, String value) throws Exception {
if (value == null || value.isBlank()) {
return "";
}
if (value == null || value.isBlank()) return "";
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
}
@@ -171,76 +233,10 @@ public final class RemoteDmSyncClient {
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 RemoteDmBatch(
long nextStoredAtMs,
String nextMessageKey,
boolean hasMore,
List<RemoteDmItem> items
) {}
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
public record RemoteDmItem(
String messageKey,
long storedAtMs,
String blobB64
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
) {}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
}
@@ -0,0 +1,35 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Логический последовательный сеанс поверх общего постоянного WSS-пула.
* close() больше не закрывает физическое соединение с сервером.
*/
public final class RemoteSyncSession implements AutoCloseable {
private final String serverLogin;
private final String serverAddress;
/** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */
public RemoteSyncSession(String serverAddressRaw) throws Exception {
this(null, serverAddressRaw);
}
public RemoteSyncSession(String serverLogin, String serverAddressRaw) {
this.serverLogin = serverLogin;
this.serverAddress = serverAddressRaw;
}
public synchronized JsonNode send(String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
serverLogin,
serverAddress,
jsonTemplate,
ServerConnectionPool.Priority.NORMAL);
}
@Override
public void close() {
// Физическое соединение принадлежит ServerConnectionPool.
}
}
@@ -4,27 +4,28 @@ 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, """
public void upsertUserSetting(
String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
upsertUserSetting(null, serverAddressRaw, entry, syncDelivery);
}
public void upsertUserSetting(
String targetServerLogin, String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
upsertUserSetting(session, entry, syncDelivery);
}
}
public void upsertUserSetting(RemoteSyncSession session, UserSettingEntry entry, boolean syncDelivery) throws Exception {
JsonNode response = session.send("""
{
"op":"UpsertUserSetting",
"requestId":%s,
@@ -63,7 +64,33 @@ public final class RemoteUserSettingsSyncClient {
int limit,
int maxBytes
) throws Exception {
JsonNode response = send(serverAddressRaw, """
return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin,
afterTimeMs, afterSettingKey, limit, maxBytes);
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
}
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
RemoteSyncSession session,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
JsonNode response = session.send("""
{
"op":"UserSettingsSyncBatch",
"requestId":%s,
@@ -110,34 +137,6 @@ public final class RemoteUserSettingsSyncClient {
);
}
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;
@@ -146,17 +145,6 @@ public final class RemoteUserSettingsSyncClient {
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,
@@ -175,35 +163,4 @@ public final class RemoteUserSettingsSyncClient {
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,763 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.SyncServersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.SyncServerEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* Общий пул постоянных WSS-соединений между физическими серверами SHiNE.
*
* <p>Пул меняет только транспорт: существующие JSON-операции, ACK, outbox и
* расписания повторов остаются обязанностью вызывающих сервисов.</p>
*/
public final class ServerConnectionPool implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(ServerConnectionPool.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final int REQUEST_WORKERS = Math.max(
8, Math.min(32, Runtime.getRuntime().availableProcessors() * 2));
private static final int CONNECTION_WORKERS = Math.max(
4, Math.min(16, Runtime.getRuntime().availableProcessors()));
private static final ServerConnectionPool INSTANCE = new ServerConnectionPool();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private final ConcurrentHashMap<String, PeerConnection> peers = new ConcurrentHashMap<>();
private final AtomicBoolean started = new AtomicBoolean(false);
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicLong requestSequence = new AtomicLong();
private final ThreadPoolExecutor requestExecutor = new ThreadPoolExecutor(
REQUEST_WORKERS,
REQUEST_WORKERS,
60L,
TimeUnit.SECONDS,
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
daemonThreadFactory("server-pool-request"),
new ThreadPoolExecutor.CallerRunsPolicy());
private final ThreadPoolExecutor connectionExecutor = new ThreadPoolExecutor(
CONNECTION_WORKERS,
CONNECTION_WORKERS,
60L,
TimeUnit.SECONDS,
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
daemonThreadFactory("server-pool-connect"),
new ThreadPoolExecutor.DiscardPolicy());
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
2, daemonThreadFactory("server-pool-scheduler"));
private ServerConnectionPool() {}
public static ServerConnectionPool getInstance() {
return INSTANCE;
}
public void startOrLog() {
if (closed.get() || !started.compareAndSet(false, true)) return;
scheduler.scheduleWithFixedDelay(this::refreshKnownPeersSafe, 0L, 30L, TimeUnit.SECONDS);
scheduler.scheduleWithFixedDelay(this::healthCheckSafe, 10L, 10L, TimeUnit.SECONDS);
scheduler.scheduleWithFixedDelay(this::logMetricsSafe, 5L, 5L, TimeUnit.MINUTES);
log.info("Server connection pool started: adaptive ping={}s pongTimeout={}s",
pingIdleSeconds(), pongTimeoutSeconds());
}
public JsonNode request(
String serverLogin,
String serverAddress,
String jsonTemplate,
Priority priority
) throws Exception {
startOrLog();
String normalizedLogin = normalizeLogin(serverLogin);
if (normalizedLogin == null) {
normalizedLogin = loginFromAddress(serverAddress);
}
if (normalizedLogin == null) {
throw new IllegalArgumentException("Server login and address are empty");
}
PeerConnection peer = registerPeer(normalizedLogin, serverAddress);
return peer.request(jsonTemplate, priority == null ? Priority.NORMAL : priority);
}
public PeerConnection registerPeer(String serverLogin, String serverAddress) {
String login = normalizeLogin(serverLogin);
String wsUrl = buildWsUrl(serverAddress);
if (login == null || wsUrl == null) {
throw new IllegalArgumentException("Invalid server peer: login=" + serverLogin + " address=" + serverAddress);
}
PeerConnection peer = peers.computeIfAbsent(login, ignored -> new PeerConnection(login, wsUrl));
peer.updateWsUrl(wsUrl);
peer.ensureConnectedInBackground(0L);
return peer;
}
public List<PeerMetricsSnapshot> snapshotMetrics() {
List<PeerMetricsSnapshot> result = new ArrayList<>();
for (PeerConnection peer : peers.values()) result.add(peer.snapshot());
result.sort(Comparator.comparing(PeerMetricsSnapshot::serverLogin));
return List.copyOf(result);
}
private void refreshKnownPeersSafe() {
if (closed.get()) return;
try {
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
Map<String, String> discovered = new LinkedHashMap<>();
for (SyncServerEntry entry : SyncServersDAO.getInstance().listAll()) {
if (entry == null) continue;
putDiscovered(discovered, ownLogin, entry.getLogin(), entry.getServerAddress());
}
for (UserAccessServerRouteEntry entry : UserAccessServersCurrentDAO.getInstance().listDistinctServers()) {
if (entry == null) continue;
putDiscovered(discovered, ownLogin, entry.getServerLogin(), entry.getServerUrl());
}
for (Map.Entry<String, String> entry : discovered.entrySet()) {
try {
registerPeer(entry.getKey(), entry.getValue());
} catch (Exception e) {
log.warn("Server pool peer registration failed: server={} reason={}",
entry.getKey(), compactError(e));
}
}
} catch (Exception e) {
log.warn("Server pool peer refresh failed: {}", compactError(e));
}
}
private static void putDiscovered(
Map<String, String> discovered,
String ownLogin,
String serverLogin,
String serverAddress
) {
String login = normalizeLogin(serverLogin);
if (login == null || login.equals(ownLogin) || buildWsUrl(serverAddress) == null) return;
discovered.put(login, serverAddress);
}
private void healthCheckSafe() {
if (closed.get()) return;
for (PeerConnection peer : peers.values()) {
try {
peer.healthCheck();
} catch (Exception e) {
log.debug("Server pool health check failed: server={} reason={}",
peer.serverLogin, compactError(e));
}
}
}
private void logMetricsSafe() {
if (closed.get() || peers.isEmpty()) return;
long connected = peers.values().stream().filter(p -> p.state == ConnectionState.CONNECTED).count();
int queued = peers.values().stream().mapToInt(p -> p.queuedCount.get()).sum();
long errors = peers.values().stream().mapToLong(p -> p.failedRequests.get()).sum();
log.info("Server pool metrics: peers={} connected={} queued={} failedRequests={}",
peers.size(), connected, queued, errors);
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) return;
for (PeerConnection peer : peers.values()) peer.closeConnection("pool_shutdown");
scheduler.shutdownNow();
requestExecutor.shutdownNow();
connectionExecutor.shutdownNow();
peers.clear();
}
public enum Priority {
REALTIME(0),
NORMAL(1),
BULK(2);
private final int rank;
Priority(int rank) { this.rank = rank; }
}
public enum ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
CLOSED
}
public record PeerMetricsSnapshot(
String serverLogin,
String wsUrl,
ConnectionState state,
long connectedAtMs,
long lastActivityAtMs,
long lastPingAtMs,
long lastPongAtMs,
long reconnectCount,
int queuedRealtime,
int queuedNormal,
int queuedBulk,
long successfulRequests,
long failedRequests,
long timedOutRequests,
String lastError
) {}
public final class PeerConnection {
private final String serverLogin;
private final PriorityBlockingQueue<QueuedRequest> queue = new PriorityBlockingQueue<>();
private final ConcurrentHashMap<String, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
private final AtomicBoolean drainScheduled = new AtomicBoolean(false);
private final AtomicBoolean reconnectScheduled = new AtomicBoolean(false);
private final AtomicInteger queuedCount = new AtomicInteger();
private final AtomicInteger reconnectAttempt = new AtomicInteger();
private final AtomicLong generation = new AtomicLong();
private final AtomicLong reconnectCount = new AtomicLong();
private final AtomicLong successfulRequests = new AtomicLong();
private final AtomicLong failedRequests = new AtomicLong();
private final AtomicLong timedOutRequests = new AtomicLong();
private final Object connectLock = new Object();
private volatile String wsUrl;
private volatile ConnectionState state = ConnectionState.DISCONNECTED;
private volatile WebSocket webSocket;
private volatile CompletableFuture<Void> readiness;
private volatile long connectedAtMs;
private volatile long lastActivityAtMs = System.currentTimeMillis();
private volatile long lastPingAtMs;
private volatile long lastPongAtMs;
private volatile long pingAwaitedSinceMs;
private volatile String lastError = "";
private PeerConnection(String serverLogin, String wsUrl) {
this.serverLogin = serverLogin;
this.wsUrl = wsUrl;
}
private JsonNode request(String jsonTemplate, Priority priority) throws Exception {
if (jsonTemplate == null || jsonTemplate.isBlank()) {
throw new IllegalArgumentException("JSON request template is empty");
}
int maxQueue = (int) configLong("server.pool.maxQueuePerPeer", 2_000L, 10L, 100_000L);
int queued = queuedCount.incrementAndGet();
if (queued > maxQueue) {
queuedCount.decrementAndGet();
throw new IllegalStateException("Server peer queue is full: " + serverLogin);
}
QueuedRequest task = new QueuedRequest(
priority,
requestSequence.incrementAndGet(),
jsonTemplate,
new CompletableFuture<>());
queue.offer(task);
scheduleDrain();
long timeoutSeconds = configLong("server.pool.callerTimeoutSeconds", 35L, 5L, 120L);
try {
return task.result.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException e) {
timedOutRequests.incrementAndGet();
if (queue.remove(task)) queuedCount.decrementAndGet();
throw new TimeoutException("Server pool caller timeout: " + serverLogin);
}
}
private void scheduleDrain() {
if (!drainScheduled.compareAndSet(false, true)) return;
requestExecutor.execute(this::drainQueue);
}
private void drainQueue() {
try {
for (;;) {
QueuedRequest task = queue.poll();
if (task == null) return;
queuedCount.decrementAndGet();
if (task.result.isDone()) continue;
try {
ensureConnectedBlocking();
JsonNode response = sendTemplate(task.jsonTemplate)
.get(requestTimeoutSeconds(), TimeUnit.SECONDS);
successfulRequests.incrementAndGet();
task.result.complete(response);
} catch (java.util.concurrent.TimeoutException e) {
timedOutRequests.incrementAndGet();
failedRequests.incrementAndGet();
task.result.completeExceptionally(
new TimeoutException("Server pool response timeout: " + serverLogin));
invalidateConnection("response_timeout", e);
} catch (Exception e) {
failedRequests.incrementAndGet();
task.result.completeExceptionally(unwrap(e));
if (state != ConnectionState.DISCONNECTED) {
invalidateConnection("request_failed", e);
}
}
}
} finally {
drainScheduled.set(false);
if (!queue.isEmpty()) scheduleDrain();
}
}
private void ensureConnectedBlocking() throws Exception {
CompletableFuture<Void> localReady;
synchronized (connectLock) {
if (state == ConnectionState.CONNECTED && webSocket != null && !webSocket.isOutputClosed()) return;
if (state == ConnectionState.CLOSED || closed.get()) {
throw new IllegalStateException("Server connection pool is closed");
}
if (readiness == null || readiness.isDone()) startConnectLocked();
localReady = readiness;
}
localReady.get(connectAndHelloTimeoutSeconds(), TimeUnit.SECONDS);
}
private void startConnectLocked() {
long currentGeneration = generation.incrementAndGet();
state = ConnectionState.CONNECTING;
lastError = "";
CompletableFuture<Void> ready = new CompletableFuture<>();
readiness = ready;
Listener listener = new Listener(this, currentGeneration);
http.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.thenCompose(ws -> {
if (generation.get() != currentGeneration) {
try { ws.abort(); } catch (Exception ignored) {}
return CompletableFuture.failedFuture(new IllegalStateException("Superseded server connection"));
}
webSocket = ws;
return sendServerHello(ws);
})
.whenComplete((ignored, error) -> {
if (error != null) {
ready.completeExceptionally(unwrap(error));
invalidateConnection(currentGeneration, "connect_or_hello_failed", error);
return;
}
if (generation.get() != currentGeneration) {
ready.completeExceptionally(new IllegalStateException("Superseded server connection"));
return;
}
long now = System.currentTimeMillis();
connectedAtMs = now;
lastActivityAtMs = now;
lastPongAtMs = now;
pingAwaitedSinceMs = 0L;
reconnectAttempt.set(0);
state = ConnectionState.CONNECTED;
ready.complete(null);
log.info("Server pool connected: server={} url={}", serverLogin, wsUrl);
});
}
private CompletableFuture<Void> sendServerHello(WebSocket ws) {
try {
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownLogin == null) ownLogin = "unconfigured-server";
String requestId = "server-hello-" + UUID.randomUUID();
CompletableFuture<JsonNode> response = new CompletableFuture<>();
pending.put(requestId, response);
String json = """
{
"op":"ServerHello",
"requestId":%s,
"payload":{
"serverLogin":%s,
"protocolVersion":1,
"capabilities":["dm-sync","settings-sync","block-sync","connection-pool"]
}
}
""".formatted(
MAPPER.writeValueAsString(requestId),
MAPPER.writeValueAsString(ownLogin));
lastActivityAtMs = System.currentTimeMillis();
ws.sendText(json, true).whenComplete((ignored, error) -> {
if (error != null) {
pending.remove(requestId);
response.completeExceptionally(error);
}
});
return response.orTimeout(requestTimeoutSeconds(), TimeUnit.SECONDS)
.thenAccept(node -> ensureOk("ServerHello", node));
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
private CompletableFuture<JsonNode> sendTemplate(String jsonTemplate) throws Exception {
WebSocket ws = webSocket;
if (state != ConnectionState.CONNECTED || ws == null || ws.isOutputClosed()) {
return CompletableFuture.failedFuture(new IllegalStateException("Server peer is disconnected"));
}
String requestId = "server-pool-" + UUID.randomUUID();
String requestIdJson = MAPPER.writeValueAsString(requestId);
String json = fillRequestId(jsonTemplate, requestIdJson);
CompletableFuture<JsonNode> response = new CompletableFuture<>();
pending.put(requestId, response);
lastActivityAtMs = System.currentTimeMillis();
ws.sendText(json, true).whenComplete((ignored, error) -> {
if (error != null) {
pending.remove(requestId);
response.completeExceptionally(error);
}
});
return response;
}
private void acceptText(long listenerGeneration, String text) {
if (generation.get() != listenerGeneration || text == null || text.isBlank()) return;
lastActivityAtMs = System.currentTimeMillis();
try {
JsonNode node = MAPPER.readTree(text);
String requestId = node.path("requestId").asText("");
CompletableFuture<JsonNode> response = requestId.isBlank() ? null : pending.remove(requestId);
if (response != null) {
response.complete(node);
} else {
log.debug("Server pool ignored unmatched frame: server={} requestId={} op={}",
serverLogin, requestId, node.path("op").asText(""));
}
} catch (Exception e) {
log.warn("Server pool received invalid JSON: server={} reason={}", serverLogin, compactError(e));
}
}
private void healthCheck() {
if (state == ConnectionState.DISCONNECTED) {
ensureConnectedInBackground(reconnectDelayMillis(reconnectAttempt.get()));
return;
}
if (state != ConnectionState.CONNECTED) return;
long now = System.currentTimeMillis();
if (pingAwaitedSinceMs > 0L) {
if (now - pingAwaitedSinceMs >= TimeUnit.SECONDS.toMillis(pongTimeoutSeconds())) {
invalidateConnection("pong_timeout", new TimeoutException("Pong timeout"));
}
return;
}
if (now - lastActivityAtMs < TimeUnit.SECONDS.toMillis(pingIdleSeconds())) return;
WebSocket ws = webSocket;
if (ws == null || ws.isOutputClosed()) {
invalidateConnection("socket_closed", null);
return;
}
long nonce = now;
pingAwaitedSinceMs = now;
lastPingAtMs = now;
ws.sendPing(ByteBuffer.allocate(Long.BYTES).putLong(0, nonce))
.whenComplete((ignored, error) -> {
if (error != null) invalidateConnection("ping_failed", error);
});
}
private void acceptPong(long listenerGeneration) {
if (generation.get() != listenerGeneration) return;
long now = System.currentTimeMillis();
lastPongAtMs = now;
lastActivityAtMs = now;
pingAwaitedSinceMs = 0L;
}
private void updateWsUrl(String newWsUrl) {
if (newWsUrl.equals(wsUrl)) return;
wsUrl = newWsUrl;
invalidateConnection("peer_url_changed", null);
}
private void ensureConnectedInBackground(long delayMs) {
if (closed.get() || state == ConnectionState.CLOSED) return;
if (!reconnectScheduled.compareAndSet(false, true)) return;
scheduler.schedule(() -> {
reconnectScheduled.set(false);
if (closed.get() || state == ConnectionState.CLOSED || state == ConnectionState.CONNECTED) return;
connectionExecutor.execute(() -> {
try {
ensureConnectedBlocking();
} catch (Exception e) {
lastError = compactError(e);
// Ошибка connect/ServerHello сама инвалидирует поколение и планирует reconnect.
// Здесь второй schedule дал бы двойной рост backoff для одной попытки.
}
});
}, Math.max(0L, delayMs), TimeUnit.MILLISECONDS);
}
private void scheduleReconnect() {
if (closed.get() || state == ConnectionState.CLOSED) return;
int attempt = reconnectAttempt.getAndUpdate(value -> Math.min(value + 1, 30));
reconnectCount.incrementAndGet();
ensureConnectedInBackground(reconnectDelayMillis(attempt));
}
private long reconnectDelayMillis(int attempt) {
long[] seconds = {1L, 2L, 4L, 8L, 15L, 30L, 60L};
long base = seconds[Math.min(Math.max(0, attempt), seconds.length - 1)];
long halfMs = TimeUnit.SECONDS.toMillis(base) / 2L;
long jitterMs = java.util.concurrent.ThreadLocalRandom.current().nextLong(halfMs + 1L);
return halfMs + jitterMs;
}
private void invalidateConnection(String reason, Throwable error) {
invalidateConnection(generation.get(), reason, error);
}
private void invalidateConnection(long expectedGeneration, String reason, Throwable error) {
if (state == ConnectionState.CLOSED
|| !generation.compareAndSet(expectedGeneration, expectedGeneration + 1L)) return;
lastError = error == null ? reason : reason + ": " + compactError(error);
state = ConnectionState.DISCONNECTED;
pingAwaitedSinceMs = 0L;
WebSocket ws = webSocket;
webSocket = null;
if (ws != null) {
try { ws.abort(); } catch (Exception ignored) {}
}
Exception failure = new IllegalStateException("Server connection lost: " + serverLogin + " (" + reason + ")");
for (Map.Entry<String, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
if (pending.remove(entry.getKey(), entry.getValue())) {
entry.getValue().completeExceptionally(failure);
}
}
scheduleReconnect();
}
private void closeConnection(String reason) {
state = ConnectionState.CLOSED;
generation.incrementAndGet();
WebSocket ws = webSocket;
webSocket = null;
if (ws != null) {
try { ws.sendClose(WebSocket.NORMAL_CLOSURE, reason); } catch (Exception ignored) {}
try { ws.abort(); } catch (Exception ignored) {}
}
IllegalStateException failure = new IllegalStateException("Server connection closed: " + serverLogin);
for (CompletableFuture<JsonNode> future : pending.values()) future.completeExceptionally(failure);
pending.clear();
for (QueuedRequest request : queue) request.result.completeExceptionally(failure);
queue.clear();
queuedCount.set(0);
}
private PeerMetricsSnapshot snapshot() {
int realtime = 0;
int normal = 0;
int bulk = 0;
for (QueuedRequest request : queue) {
switch (request.priority) {
case REALTIME -> realtime++;
case NORMAL -> normal++;
case BULK -> bulk++;
}
}
return new PeerMetricsSnapshot(
serverLogin, wsUrl, state, connectedAtMs, lastActivityAtMs,
lastPingAtMs, lastPongAtMs, reconnectCount.get(), realtime, normal, bulk,
successfulRequests.get(), failedRequests.get(), timedOutRequests.get(), lastError);
}
}
private record QueuedRequest(
Priority priority,
long sequence,
String jsonTemplate,
CompletableFuture<JsonNode> result
) implements Comparable<QueuedRequest> {
@Override
public int compareTo(QueuedRequest other) {
int byPriority = Integer.compare(priority.rank, other.priority.rank);
return byPriority != 0 ? byPriority : Long.compare(sequence, other.sequence);
}
}
private static final class Listener implements WebSocket.Listener {
private final PeerConnection peer;
private final long generation;
private final StringBuilder text = new StringBuilder();
private Listener(PeerConnection peer, long generation) {
this.peer = peer;
this.generation = generation;
}
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
text.append(data);
if (last) {
peer.acceptText(generation, text.toString());
text.setLength(0);
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onPong(WebSocket webSocket, ByteBuffer message) {
peer.acceptPong(generation);
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
peer.invalidateConnection(generation, "remote_close_" + statusCode, null);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
peer.invalidateConnection(generation, "websocket_error", error);
}
}
public static String buildWsUrl(String serverAddressRaw) {
if (serverAddressRaw == null) return null;
String raw = serverAddressRaw.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
int port = uri.getPort();
String authority = host.trim().toLowerCase(Locale.ROOT) + (port > 0 ? ":" + port : "");
return "wss://" + authority + "/ws";
} catch (Exception e) {
return null;
}
}
private static String loginFromAddress(String serverAddress) {
String wsUrl = buildWsUrl(serverAddress);
if (wsUrl == null) return null;
try {
return "address:" + URI.create(wsUrl).getAuthority().toLowerCase(Locale.ROOT);
} catch (Exception e) {
return null;
}
}
private static String normalizeLogin(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static void ensureOk(String op, JsonNode response) {
int status = response == null ? 500 : response.path("status").asInt(500);
if (status >= 200 && status < 300) return;
String code = response == null ? "EMPTY_RESPONSE" : response.path("code").asText("");
if (code.isBlank() && response != null) code = response.path("error").asText("");
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
}
private static String fillRequestId(String template, String requestIdJson) {
int marker = template.indexOf("%s");
if (marker < 0) throw new IllegalArgumentException("JSON template has no requestId marker");
return template.substring(0, marker) + requestIdJson + template.substring(marker + 2);
}
private static Throwable unwrap(Throwable error) {
Throwable current = error;
while ((current instanceof java.util.concurrent.CompletionException
|| current instanceof java.util.concurrent.ExecutionException)
&& current.getCause() != null) {
current = current.getCause();
}
return current;
}
private static Exception unwrap(Exception error) {
Throwable unwrapped = unwrap((Throwable) error);
return unwrapped instanceof Exception e ? e : new IllegalStateException(unwrapped);
}
private static String compactError(Throwable error) {
String text = String.valueOf(error == null ? "unknown" : error.getMessage());
return text.length() <= 500 ? text : text.substring(0, 500);
}
private static long requestTimeoutSeconds() {
return configLong("server.pool.requestTimeoutSeconds", 12L, 3L, 120L);
}
private static long connectAndHelloTimeoutSeconds() {
return configLong("server.pool.connectTimeoutSeconds", 15L, 5L, 120L);
}
private static long pingIdleSeconds() {
return configLong("server.pool.pingIdleSeconds", 120L, 15L, 240L);
}
private static long pongTimeoutSeconds() {
return configLong("server.pool.pongTimeoutSeconds", 15L, 5L, 120L);
}
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 value = Long.parseLong(raw.trim());
return Math.max(min, Math.min(max, value));
} catch (Exception ignored) {
return defaultValue;
}
}
private static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private final AtomicLong sequence = new AtomicLong();
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, prefix + "-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
}
};
}
}
@@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup {
blockchainName, partnerLogin, partnerAddress);
try {
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress);
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
.findFirst()
@@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService {
if (partnerLogin == null) return;
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
REMOTE.listBlockchainHeads(partner.getServerAddress());
REMOTE.listBlockchainHeads(partner.getLogin(), partner.getServerAddress());
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
@@ -170,7 +170,8 @@ public final class PeriodicBlockchainSyncService {
int fromBlockNumber = Math.max(localLast + 1, 0);
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
if (remoteBlock == null) {
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, remoteHead.blockchainName(), blockNumber);
@@ -284,7 +285,8 @@ public final class PeriodicBlockchainSyncService {
String localPrevHash = "";
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber);
REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), blockchainName, blockNumber);
if (remoteBlock == null) {
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, blockchainName, blockNumber);
@@ -2,165 +2,52 @@ package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
import shine.db.dao.DmSyncPeerStateDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncPeerStateEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import shine.db.entities.DmDeliveryStateEntry;
import utils.config.AppConfig;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
*/
/** Пятисекундный воркер отвечает только за доставку DM получателю. */
public final class PeriodicDmSyncService {
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "periodic-dm-sync");
t.setDaemon(true);
return t;
}
});
private static final ScheduledExecutorService SCHEDULER =
Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("dm-worker-dispatcher"));
private static final ThreadPoolExecutor DELIVERY_EXECUTOR = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(500),
daemonThreadFactory("dm-delivery"), new ThreadPoolExecutor.DiscardPolicy());
private PeriodicDmSyncService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
log.info("DM delivery worker disabled by dm.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) {
return;
}
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
EXECUTOR.scheduleWithFixedDelay(
PeriodicDmSyncService::runCycleSafe,
initialDelaySec,
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
if (!STARTED.compareAndSet(false, true)) return;
long pollSeconds = configLong("dm.worker.pollSeconds", 5L, 1L, 60L);
SCHEDULER.scheduleWithFixedDelay(
PeriodicDmSyncService::tickSafe, 0L, pollSeconds, TimeUnit.SECONDS);
log.info("DM delivery worker scheduled every {} seconds", pollSeconds);
}
private static void runCycleSafe() {
private static void tickSafe() {
try {
runCycle();
int limit = (int) configLong("dm.worker.dueLimit", 100L, 1L, 1000L);
for (DmDeliveryStateEntry row : DmDeliveryCoordinator.listDue(limit)) {
DELIVERY_EXECUTOR.execute(() -> DmDeliveryCoordinator.processDueEntry(row));
}
} catch (Exception e) {
log.error("Periodic DM sync failed unexpectedly", e);
log.error("DM delivery dispatcher failed", e);
}
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
log.warn("Periodic DM 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 DM sync skipped: no local access-server users for {}", ownServerLogin);
return;
}
int syncedPeers = 0;
int appliedEvents = 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 {
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
ownerLogin, route.getServerLogin(), String.valueOf(e));
}
}
}
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
owners.size(), syncedPeers, appliedEvents);
}
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
long cursorStoredAtMs = state.getCursorStoredAtMs();
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
int applied = 0;
boolean bootstrapCompleted = false;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
route.getServerUrl(),
ownerLogin,
cursorStoredAtMs,
cursorMessageKey,
limit,
maxBytes
);
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
if (result.status().applied()) {
applied++;
}
}
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
bootstrapCompleted = !batch.hasMore();
STATE_DAO.updateSuccess(
ownerLogin,
route.getServerLogin(),
route.getServerUrl(),
cursorStoredAtMs,
cursorMessageKey,
bootstrapCompleted
);
if (!batch.hasMore() || batch.items().isEmpty()) {
break;
}
}
if (!bootstrapCompleted) {
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
ownerLogin, route.getServerLogin(), maxPages);
}
return applied;
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
@@ -169,17 +56,18 @@ public final class PeriodicDmSyncService {
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;
}
try { return Math.max(min, Math.min(max, Long.parseLong(raw.trim()))); }
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 static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private int sequence;
@Override public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + (++sequence));
t.setDaemon(true);
return t;
}
};
}
}
@@ -2,6 +2,7 @@ package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.dao.UserSettingsSyncPeerStateDAO;
@@ -14,6 +15,7 @@ import java.sql.Connection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.ArrayList;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -26,6 +28,7 @@ public final class PeriodicUserSettingsSyncService {
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 RemoteDmSyncClient DM_REMOTE = new RemoteDmSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance();
private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance();
@@ -55,6 +58,9 @@ public final class PeriodicUserSettingsSyncService {
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
EXECUTOR.scheduleWithFixedDelay(
PeriodicUserSettingsSyncService::runRequestedCycleSafe,
5L, 5L, TimeUnit.SECONDS);
log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
}
@@ -66,6 +72,10 @@ public final class PeriodicUserSettingsSyncService {
}
}
private static void runRequestedCycleSafe() {
if (DmSyncWakeSignal.consume()) runCycleSafe();
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
@@ -83,6 +93,7 @@ public final class PeriodicUserSettingsSyncService {
int syncedPeers = 0;
int appliedItems = 0;
int pushedItems = 0;
int appliedDmItems = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
@@ -96,6 +107,7 @@ public final class PeriodicUserSettingsSyncService {
SyncStats stats = syncOwnerWithRemote(ownerLogin, route);
appliedItems += stats.applied();
pushedItems += stats.pushed();
appliedDmItems += stats.appliedDm();
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
@@ -105,8 +117,8 @@ public final class PeriodicUserSettingsSyncService {
}
}
log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}",
owners.size(), syncedPeers, appliedItems, pushedItems);
log.info("Periodic access-data sync finished: owners={} peers={} settingsApplied={} settingsPushed={} dmApplied={}",
owners.size(), syncedPeers, appliedItems, pushedItems, appliedDmItems);
}
private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
@@ -124,9 +136,11 @@ public final class PeriodicUserSettingsSyncService {
int pushed = 0;
boolean bootstrapCompleted = false;
int appliedDm;
try (RemoteSyncSession session = new RemoteSyncSession(route.getServerLogin(), route.getServerUrl())) {
for (int page = 0; page < maxPages; page++) {
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
route.getServerUrl(),
session,
ownerLogin,
cursorTimeMs,
cursorSettingKey,
@@ -163,17 +177,53 @@ public final class PeriodicUserSettingsSyncService {
try (Connection c = getDbConnection()) {
List<UserSettingEntry> unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit);
for (UserSettingEntry entry : unsynced) {
REMOTE.upsertUserSetting(route.getServerUrl(), entry, true);
REMOTE.upsertUserSetting(session, entry, true);
SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey());
pushed++;
}
}
int dmLimit = (int) configLong("dm.sync.batchLimit", 200L, 1L, 500L);
int dmMaxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int dmMaxPages = (int) configLong("dm.sync.maxPagesPerPeer", 20L, 1L, 500L);
appliedDm = syncDmInSameSession(session, ownerLogin, dmLimit, dmMaxBytes, dmMaxPages);
}
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);
return new SyncStats(applied, pushed, appliedDm);
}
private static int syncDmInSameSession(
RemoteSyncSession session, String ownerLogin, int limit, int maxBytes, int maxPages
) throws Exception {
long cursorMs = 0L;
String cursorKey = "";
List<String> acknowledgements = new ArrayList<>();
int applied = 0;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = DM_REMOTE.dmSyncBatch(
session, ownerLogin, cursorMs, cursorKey, Math.min(limit, 500), maxBytes, acknowledgements);
acknowledgements = new ArrayList<>();
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.syncId() == null || item.syncId().isBlank()) continue;
DmSyncApplySupport.applySyncedItem(ownerLogin, item.syncId(), item.blobsB64());
acknowledgements.add(item.syncId());
applied++;
}
cursorMs = batch.nextStoredAtMs();
cursorKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
if (!batch.hasMore() || batch.items().isEmpty()) {
if (!acknowledgements.isEmpty()) {
DM_REMOTE.dmSyncBatch(session, ownerLogin, 0L, "",
Math.min(limit, 500), maxBytes, acknowledgements);
}
break;
}
}
return applied;
}
private static java.sql.Connection getDbConnection() throws Exception {
@@ -202,5 +252,5 @@ public final class PeriodicUserSettingsSyncService {
return s.isEmpty() ? null : s;
}
private record SyncStats(int applied, int pushed) {}
private record SyncStats(int applied, int pushed, int appliedDm) {}
}
@@ -83,3 +83,11 @@ WebSocket-эндпоинт для одного соединения.
- `WsServer` = сервер, который слушает порт и вешает `/ws`.
- `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой.
## Постоянные server-to-server соединения
Исходящие межсерверные JSON-запросы проходят через
`server.sync.ServerConnectionPool`. Пул держит одно постоянное исходящее WSS-
соединение на `serverLogin`, отправляет `ServerHello`, поддерживает канал через
WebSocket ping/pong и переподключает его с backoff. DM, settings и blockchain
используют это соединение с разными приоритетами, не меняя свою бизнес-логику.
@@ -12,6 +12,7 @@ import server.sync.PeriodicDmSyncService;
import server.sync.PeriodicUserSettingsSyncService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import server.sync.ServerConnectionPool;
import utils.config.AppConfig;
import java.time.Duration;
@@ -104,6 +105,10 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
ServerConnectionPool.getInstance().startOrLog();
Runtime.getRuntime().addShutdownHook(new Thread(
() -> ServerConnectionPool.getInstance().close(),
"server-connection-pool-shutdown"));
PeriodicDmSyncService.startOrLog();
PeriodicUserSettingsSyncService.startOrLog();
server.join();
@@ -34,13 +34,19 @@ sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------
server.version=${projectVersion}
# Межсерверная догоняющая синхронизация личных сообщений.
# Доставка и межсерверная синхронизация личных сообщений.
dm.sync.enabled=true
dm.sync.initialDelaySeconds=60
dm.sync.periodHours=6
dm.sync.batchLimit=500
dm.worker.pollSeconds=5
dm.worker.dueLimit=100
dm.sync.batchLimit=200
dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=50
dm.sync.maxPagesPerPeer=20
server.pool.pingIdleSeconds=120
server.pool.pongTimeoutSeconds=15
server.pool.requestTimeoutSeconds=12
server.pool.connectTimeoutSeconds=15
server.pool.callerTimeoutSeconds=35
server.pool.maxQueuePerPeer=2000
server.info.url=
server.info.physicalRegion=
server.info.description=
@@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody;
import blockchain.body.TextBody;
import shine.db.DbController;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
import test.it.utils.TestConfig;
@@ -13,6 +14,10 @@ import test.it.utils.log.TestResult;
import test.it.utils.ws.WsSession;
import java.time.Duration;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
@@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth {
st1.registerTextChannelRoot(newsRootBlock, newsRootHash);
}
// CREATE_CHANNEL "Updates" второй публичный канал того же владельца
int updatesRootBlock;
byte[] updatesRootHash;
{
var ln = st1.nextLineByType(ChainState.TYPE_TECH);
sender1.send(new CreateChannelBody(
0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"Updates",
"",
CreateChannelBody.CHANNEL_TYPE_PUBLIC,
CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT
), t);
updatesRootBlock = st1.lastBlockNumber();
updatesRootHash = st1.getHash32(updatesRootBlock);
assertNotNull(updatesRootHash);
st1.registerTextChannelRoot(updatesRootBlock, updatesRootHash);
}
// POST #0 в канал "News"
int newsPost0Block;
byte[] newsPost0Hash;
@@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth {
bch1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) FRIEND взаимно (на HEADER)
// 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1),
"U2 должен иметь две отдельные записи подписки на каналы U1");
assertEquals(2, countFollowingChannels(u2),
"following_channels_count должен учитывать два разных канала одного владельца");
assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash),
"У канала News должен быть 1 подписчик");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"У канала Updates должен быть 1 подписчик");
// 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t);
@@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth {
bch1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t);
// 4) CONTACT несколько
// 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t);
@@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth {
bch3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t);
// 5) U1 убирает U2 из контактов (UNCONTACT)
// 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1),
"После отписки от одного канала должна остаться одна запись подписки");
assertEquals(1, countFollowingChannels(u2),
"following_channels_count должен уменьшиться ровно на 1");
assertEquals(0, countSubscribers(bch1, newsRootBlock, newsRootHash),
"После отписки от News у канала должен остаться 0 подписчиков");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"Подписка на Updates должна остаться");
// 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t);
@@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth {
toBlockHash32
), timeout);
}
private static int countConnectionsByOwner(String login, String ownerLogin) {
return queryInt("""
SELECT COUNT(*)
FROM connections_state
WHERE login = ?
AND rel_type = 30
AND to_login = ?
""", login, ownerLogin);
}
private static int countFollowingChannels(String login) {
return queryInt("""
SELECT following_channels_count
FROM user_stats_state
WHERE login = ?
""", login);
}
private static int countSubscribers(String ownerBlockchainName, int rootBlockNumber, byte[] rootBlockHash) {
return queryInt("""
SELECT COALESCE(subscribers_count, 0)
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
AND channel_root_block_hash = ?
""", ownerBlockchainName, rootBlockNumber, rootBlockHash);
}
private static int queryInt(String sql, Object... params) {
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
Object p = params[i];
if (p instanceof String s) {
ps.setString(i + 1, s);
} else if (p instanceof Integer n) {
ps.setInt(i + 1, n);
} else if (p instanceof byte[] bytes) {
ps.setBytes(i + 1, bytes);
} else {
throw new IllegalArgumentException("Unsupported SQL param type: " + (p == null ? "null" : p.getClass()));
}
}
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("Query returned no rows: " + sql);
}
return rs.getInt(1);
}
} catch (SQLException e) {
throw new RuntimeException("DB query failed: " + sql, e);
}
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
backup.schema.version=2
backup.full.version=3
last.full.backup.date=2026-08-08
backup.full.version=4
last.full.backup.date=2026-08-25
+9 -1
View File
@@ -57,7 +57,11 @@
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,
"serverBlockchainSizeLimitBytes": 100000
"serverBlockchainSizeLimitBytes": 100000,
"ownedPublicChannelsCount": 3,
"followingUsersCount": 18,
"followingChannelsCount": 27,
"closeFriendsCount": 4
}
}
```
@@ -68,6 +72,10 @@
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь;
- `followingUsersCount` — количество пользователей, на которых подписан пользователь;
- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь;
- `closeFriendsCount` — количество близких друзей пользователя.
### Успешный ответ: пользователя нет
+4
View File
@@ -23,6 +23,9 @@
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
---
@@ -155,6 +158,7 @@
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"subscribersCount": 128,
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"metaEvents": [
+7 -3
View File
@@ -32,6 +32,7 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
@@ -62,11 +63,12 @@
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | старая полная DM-пара для второго access-сервера отправителя |
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `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 по курсору |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | pull событий `synced=false` с ACK предыдущей страницы |
| `GetDmDeliveryStatus` | `12_Direct_Messages_Push_Calls_API.md` | read-only статус доставки по существующему `messageKey` |
| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору |
| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных |
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
@@ -76,7 +78,9 @@
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
- Отдельных 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`
+115 -34
View File
@@ -11,6 +11,8 @@
- для 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`
@@ -105,12 +107,17 @@
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveryState": "accepted",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`.
Возможные `deliveryState`: `accepted`, `delivered`, `failed`.
### Ошибки
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
@@ -145,7 +152,15 @@
}
```
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов.
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
### Примечание
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
## 5. `DeleteMessage`
@@ -235,6 +250,7 @@
"reencryptedAtMs": 0,
"createdAtMs": 1774700001123,
"readAtMs": 1774700001456,
"deliveryState": "delivered",
"blobB64": "BASE64_SIGNED_BLOCK"
}
]
@@ -244,67 +260,113 @@
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
## 8. `DmSyncBatch`
Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`.
Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`.
## 8. Межсерверные операции DM
### Запрос
До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи.
### 8.1. `ReceiveOutcomingMessage`
Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит.
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"op": "ReceiveOutcomingMessage",
"requestId": "dm-peer-001",
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 1774700000000,
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
"limit": 500,
"maxBytes": 3000000
"incomingBlobB64": "BASE64_INCOMING",
"outgoingBlobB64": "BASE64_OUTGOING",
"sourceServerLogin": "server-a"
}
}
```
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки.
### Успешный ответ
### 8.2. `ReceiveIncomingMessage`
Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя.
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-incoming-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING",
"sourceServerLogin": "server-a"
}
}
```
Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись).
### 8.3. `DmSyncBatch`
Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа.
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"status": 200,
"ok": true,
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 0,
"afterMessageKey": "",
"limit": 500,
"rawBytes": 84512,
"hasMore": true,
"nextStoredAtMs": 1774700100000,
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
"maxBytes": 3000000,
"ackSyncIds": []
}
}
```
Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла:
```json
{
"nextStoredAtMs": 1774700001123,
"nextMessageKey": "alice|bob|1774700000123|123456789|2",
"hasMore": false,
"items": [
{
"messageKey": "alice|bob|1774700000123|123456789|1",
"baseKey": "alice|bob|1774700000123|123456789",
"targetLogin": "alice",
"fromLogin": "bob",
"toLogin": "alice",
"messageType": 1,
"timeMs": 1774700000123,
"storedAtMs": 1774700100000,
"blobB64": "BASE64_SIGNED_BLOCK"
"syncId": "alice|bob|1774700000123|123456789|2:0:0",
"primaryMessageKey": "alice|bob|1774700000123|123456789|2",
"storedAtMs": 1774700001123,
"blobsB64": ["BASE64_INCOMING", "BASE64_OUTGOING"]
}
]
}
```
Для пары порядок всегда incoming/outgoing; для входящей копии и tombstone массив содержит один blob. `syncId` — технический идентификатор конкретной ревизии только для ACK синхронизации, а не второй ID сообщения. После сохранения вызывающий сервер передаёт `syncId` в `ackSyncIds` следующего запроса. Только тогда источник ставит `synced=true`. Новый цикл начинается с `afterStoredAtMs=0`; полный сброс флагов поэтому повторно отдаёт всю историю.
### 8.4. `GetDmDeliveryStatus`
Единственная новая межсерверная операция доставки. Read-only проверка существующего `messageKey`; не изменяет состояние отвечающего сервера.
```json
{
"op": "GetDmDeliveryStatus",
"requestId": "dm-status-001",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя.
Ошибки:
Периодический процесс использует один логический последовательный сеанс поверх
постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает
`DmSyncBatch` до завершения страниц и ACK. Существующий
`MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает
`dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет.
- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin`
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя
- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login`
Основные ошибки межсерверных операций:
- `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`;
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`;
- `500 / LOCAL_SERVER_NOT_CONFIGURED`.
## 9. `AckSessionDelivery`
@@ -347,6 +409,25 @@
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 10.1. Событие `DmDeliveryStateChanged`
Сервер отправляет событие активным сессиям отправителя при изменении сетевого состояния исходящей пары.
```json
{
"op": "DmDeliveryStateChanged",
"event": true,
"status": 200,
"payload": {
"baseKey": "alice|bob|1774700000123|123456789",
"outgoingKey": "alice|bob|1774700000123|123456789|2",
"deliveryState": "delivered"
}
}
```
`failed` приходит после последней неудачной попытки через час. Событие прочтения остаётся отдельным существующим read-receipt.
## 11. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
@@ -359,7 +440,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-файлов сейчас отсутствуют
+9 -3
View File
@@ -13,7 +13,8 @@
- `value_num = number of messages already seen in channel`;
- `value_text = ''`.
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
## 2. Структура записи
@@ -76,15 +77,20 @@
Внутренний служебный запрос.
- помечает все настройки пользователя или все настройки сразу как `synced=false`;
- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной;
- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий;
- нужен после добавления нового sync-сервера или при потере локальной БД.
## 5. Синхронизация
Синхронизация настроек работает отдельно от DM.
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс
использует один логический последовательный сеанс поверх постоянного WSS-пула:
сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение
логического сеанса не закрывает физический сокет.
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору;
- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM;
- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля.
## 6. Текущий UI-кейс
+105
View File
@@ -0,0 +1,105 @@
# Межсерверное соединение и `ServerHello`
Документ описывает транспортный слой постоянных WSS-соединений между серверами SHiNE.
Он не меняет форматы DM, блоков, настроек, ACK или расписания повторных попыток.
## 1. `ServerHello`
После установления исходящего WSS-соединения сервер первым запросом отправляет:
```json
{
"op": "ServerHello",
"requestId": "server-hello-001",
"payload": {
"serverLogin": "shineupme",
"protocolVersion": 1,
"capabilities": [
"dm-sync",
"settings-sync",
"block-sync",
"connection-pool"
]
}
}
```
Успешный ответ:
```json
{
"op": "ServerHello",
"requestId": "server-hello-001",
"status": 200,
"payload": {
"accepted": true,
"serverLogin": "server2",
"protocolVersion": 1,
"capabilities": [
"dm-sync",
"settings-sync",
"block-sync",
"connection-pool"
]
}
}
```
На текущем этапе `serverLogin` принимается на доверии. Подпись, challenge и
проверка корневого ключа сервера намеренно отложены. Поэтому `ServerHello`
фиксирует тип соединения и возможности peer, но пока не является
криптографической аутентификацией.
## 2. Пул соединений
- физическое соединение создаётся одно на `serverLogin`;
- логические операции DM, settings и blockchain используют один WSS;
- завершение `RemoteSyncSession` не закрывает физический сокет;
- известные peer берутся из `sync_servers` и `user_access_servers_current`;
- список перечитывается каждые 30 секунд;
- при изменении URL соединение пересоздаётся;
- новые запросы не повторяются транспортом автоматически: действующие
domain-воркеры сохраняют прежние правила retry и идемпотентности.
## 3. Приоритеты
| Приоритет | Операции |
| --- | --- |
| `REALTIME` | доставка DM, deletes, `GetDmDeliveryStatus` |
| `NORMAL` | настройки, `DmSyncBatch` и access-data sync |
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
На одном peer одновременно исполняется один запрос. Приоритет применяется к
ожидающей очереди и не прерывает уже начатую операцию.
## 4. Ping, reconnect и таймауты
Значения по умолчанию:
| Параметр | Значение |
| --- | ---: |
| `server.pool.pingIdleSeconds` | `120` |
| `server.pool.pongTimeoutSeconds` | `15` |
| `server.pool.requestTimeoutSeconds` | `12` |
| `server.pool.connectTimeoutSeconds` | `15` |
| `server.pool.callerTimeoutSeconds` | `35` |
| `server.pool.maxQueuePerPeer` | `2000` |
Ping отправляется WebSocket control-frame только после периода отсутствия
полезного трафика. При потере соединения применяется reconnect с jitter и
ступенями до 60 секунд.
## 5. Диагностика
Внутренний snapshot пула хранит для каждого peer:
- состояние соединения;
- URL;
- времена connect/activity/ping/pong;
- число reconnect;
- размеры очередей по приоритетам;
- успешные, ошибочные и просроченные запросы;
- последнюю ошибку.
Агрегированное состояние периодически записывается в серверный лог. Отдельная
публичная операция метрик на этом этапе не добавляется.
+13
View File
@@ -1,5 +1,18 @@
# История изменений документации блокчейна
## 2026-08-25 19:45:18 +0400
- Базовый коммит-ориентир: `3a58519`.
- Добавлены runtime-агрегаты статистики:
- `user_stats_state` хранит `owned_public_channels_count`, `following_users_count`, `following_channels_count`, `close_friends_count`;
- `channel_stats_state` хранит `subscribers_count` для публичных каналов.
- Обновление статистики выполняется атомарно вместе с записью блока в той же SQL-транзакции `AddBlock`.
- После полного resync статистика пересобирается из текущих `connections_state` и `channel_names_state`, чтобы старые данные продолжали работать.
## 2026-08-25 19:59:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Триггер `channel_names_state` для статистики сделан устойчивым к старым данным, где канал уже есть, а владельца ещё нет в `solana_user_pda_current`.
- Добавлена совместимая миграция `v15`, чтобы существующие базы перестали падать на старых каналах при старте сервера.
## 2026-08-12 13:00:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Канонический текстовый формат служебных тегов сокращён:
+2
View File
@@ -42,4 +42,6 @@
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync.
- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
+38 -22
View File
@@ -32,9 +32,11 @@
### 3.1 Личные сообщения (DM)
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
- Репликация между двумя access-серверами пользователя работает через `dm_sync_outbox.synced`, без постоянного time-cursor.
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 3.2 Блоки пользовательского блокчейна
@@ -181,25 +183,32 @@ Full resync запускается только тогда, когда:
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Целевой протокол следующего этапа
## 5. Реализованный постоянный server-to-server транспорт
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
- Один физический канал переиспользуют DM, настройки и blockchain.
### 5.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 5.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
- Сервер с более полной историей досылает недостающее партнёру.
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
### 5.4 Разрешение конфликтов
@@ -212,19 +221,21 @@ Full resync запускается только тогда, когда:
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
- Сначала проверяет локально (если B зарегистрирован на X).
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
2. Сервер X валидирует и локально сохраняет пару.
3. До ответа клиенту X параллельно отправляет входящую копию максимум двум актуальным `access_servers` B.
4. ACK хотя бы одного маршрута B означает терминальный `delivered`; второй сервер B догоняется собственной DM-синхронизацией.
5. После первой попытки X передаёт полную пару второму access-серверу A старым `ReceiveOutcomingMessage`, без delivery-state.
6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через `GetDmDeliveryStatus(messageKey)`.
7. После неудачной попытки через час ставится терминальный `failed`.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
## 7. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами.
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным.
## 8. Статус реализации
@@ -239,16 +250,21 @@ Full resync запускается только тогда, когда:
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
| Криптографическая server-to-server авторизация DM | Нужна реализация |
Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
Текущая версия сервера использует постоянный WSS-пул для существующих
server-to-server JSON-операций. Отдельной будущей задачей остаётся
криптографическая авторизация server-to-server вызовов.
Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
+1
View File
@@ -6,6 +6,7 @@
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — состояния доставки, retry-воркер, репликация между двумя access-серверами и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -0,0 +1,138 @@
# Доставка и синхронизация личных сообщений
## 1. Главный принцип
DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером.
Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю.
Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные.
## 2. Идентификаторы
- `baseKey` связывает входящую и исходящую копии одного логического сообщения;
- `incomingKey` идентифицирует копию получателя;
- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки;
- дополнительный публичный `eventId` для доставки не создаётся;
- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`.
## 3. Состояния доставки
| Состояние | Смысл | Повторные попытки |
|---|---|---|
| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да |
| `delivered` | хотя бы один сервер получателя подтвердил запись | нет |
| `failed` | последняя попытка через час завершилась без доставки | никогда |
Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя.
## 4. Обычная отправка
1. Клиент отправляет `SendMessagePair` на один свой access-сервер.
2. Сервер проверяет формат, пользователей, подписи и согласованность пары.
3. Сервер атомарно сохраняет входящую и исходящую копии.
4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя.
5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно.
6. Если хотя бы один вызов успешен, устанавливается `delivered`.
7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`.
8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`.
Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными.
## 5. Расписание повторов
Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений.
Попытки привязаны к времени первоначального принятия:
| Номер | Время от старта | Сначала спросить второй сервер отправителя |
|---:|---:|---|
| 1 | сразу | нет |
| 2 | 30 секунд | нет |
| 3 | 5 минут | да |
| 4 | 25 минут | да |
| 5 | 1 час | да |
Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит.
На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя:
```json
{
"op": "GetDmDeliveryStatus",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
Ответ:
```json
{
"messageKey": "alice|bob|1774700000123|123456789|2",
"known": true,
"delivered": true
}
```
Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку.
Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается.
Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется.
## 6. Старая межсерверная доставка
Форматы существующих операций не расширяются данными о результате доставки:
- `ReceiveOutcomingMessage` получает прежнюю пару `incomingBlobB64` + `outgoingBlobB64` и необязательный `sourceServerLogin`;
- `ReceiveIncomingMessage` получает один `incomingBlobB64` и необязательный `sourceServerLogin`.
Второй сервер отправителя после получения пары создаёт собственное локальное состояние доставки и самостоятельно пробует маршруты получателя. Серверы обмениваются результатом только через read-only `GetDmDeliveryStatus`.
## 7. Догоняющая синхронизация двух серверов пользователя
Для каждого владельца сервер ведёт outbox с флагом `synced`:
- исходящая пара отправителя — один элемент с двумя blob в порядке incoming/outgoing;
- входящая копия получателя — один элемент с одним blob;
- read-receipt и tombstone применяются теми же проверенными обработчиками.
`DmSyncBatch` возвращает только элементы с `synced=false`. Получатель проверяет signed-блоки, сохраняет их идемпотентно и в следующем запросе подтверждает `syncId` через `ackSyncIds`. Источник ставит `synced=true` только после ACK.
При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля.
`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`.
Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы.
Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь.
## 8. UI
| Вид | Значение |
|---|---|
| одна серая галочка | собственный сервер принял сообщение (`accepted`) |
| одна светлая галочка | хотя бы один сервер получателя принял сообщение (`delivered`) |
| две светлые галочки | пришёл существующий read-receipt |
| красный `!` и «Сообщение не доставлено» | окончательное состояние `failed` |
Read-receipt имеет приоритет над delivery-индикатором: если сообщение прочитано, оно заведомо было доставлено.
Сервер сообщает поздние изменения через `DmDeliveryStateChanged` с полями `outgoingKey`, `baseKey`, `deliveryState`. При повторном открытии чата то же состояние приходит в `GetDirectMessages`.
## 9. Нагрузка и отказоустойчивость
- воркер выбирает только due-записи по частичному индексу;
- терминальные строки не попадают в рабочую выборку;
- два маршрута первой попытки выполняются параллельно;
- ограниченный пул потоков и очередь защищают сервер при всплеске отправок;
- сетевой lease и сравнение версии строки предотвращают одновременную обработку одной задачи несколькими worker-потоками;
- повторная запись одного signed-блока безопасна;
- отсутствие второго сервера отправителя не мешает собственной доставке;
- отсутствие обоих серверов получателя завершает задачу через час.
## 10. Граница доверия
Межсерверная авторизация пока отложена. `sourceServerLogin` временно принимается на доверии, но каждый контейнер `SHiNE_DM` всё равно проходит проверку пользовательской подписи. `GetDmDeliveryStatus` сообщает только факт локального delivery-state и не изменяет данные.
@@ -26,6 +26,10 @@
- `docs/Personal_Messages/Технические_вставки_DM_v1.md`
Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно:
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`
Устаревшая предыдущая версия сохранена отдельно:
- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
@@ -300,12 +304,17 @@
`sync_servers` не являются списком пользовательских серверов доставки DM.
### 7.3. Несколько серверов у отправителя и получателя
### 7.3. Два сервера у отправителя и получателя
Протокол должен поддерживать ситуацию, когда:
Актуальный runtime исходит из ограничения:
- у отправителя несколько `access_servers`;
- у получателя несколько `access_servers`;
- у одного пользователя не более двух `access_servers`;
- следовательно, у локального сервера есть не более одного peer для репликации данных пользователя.
Протокол поддерживает ситуацию, когда:
- у отправителя один или два `access_servers`;
- у получателя один или два `access_servers`;
- часть серверов у сторон совпадает;
- часть серверов уникальна.
@@ -332,7 +341,33 @@
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### 8.2. Новые методы, которые нужны
### 8.2. Межсерверная синхронизация
- `ReceiveOutcomingMessage` — прежняя полная пара второго access-сервера отправителя;
- `ReceiveIncomingMessage` — прежняя одиночная входящая копия;
- `DmSyncBatch` — pull событий с `synced=false` и ACK сохранённой предыдущей страницы;
- `GetDmDeliveryStatus` — единственная новая read-only проверка доставки по существующему `messageKey`.
Delivery-state между серверами не передаётся и не объединяется.
### 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. Правила валидации и применения
@@ -427,8 +462,12 @@ Request:
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- успешный `status=200` означает локальное сохранение;
- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`.
### 10.2. `ReceiveIncomingMessage`
Назначение:
@@ -450,7 +489,7 @@ Request:
}
```
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
### 10.3. `DeleteMessage`
@@ -539,6 +578,8 @@ UI-следствие для клиента:
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
Для исходящих элементов сервер также возвращает единственное поле `deliveryState`.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
@@ -549,47 +590,23 @@ UI-следствие для клиента:
### 11.2. Серверная сторона
После принятия валидного события сервер должен отправлять его:
После локального принятия пара получает изменяемое состояние `accepted`. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное `delivered`; ждать второй маршрут не требуется.
- на все серверы из `access_servers` отправителя;
- на все серверы из `access_servers` получателя.
После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний `ReceiveOutcomingMessage`. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит.
Если часть серверов совпадает, это допустимо.
Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии.
Идемпотентность обязательна.
Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK.
### 11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция:
Для восстановления пропущенных DM-событий между access-серверами используется pull-операция:
- `DmSyncBatch`
Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору:
Второй сервер запрашивает outbox-события владельца с `synced=false`, сохраняет их и передаёт подтверждённые `syncId` в `ackSyncIds` следующего запроса. Источник ставит `synced=true` только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла.
- `ownerLogin`;
- `afterStoredAtMs`;
- `afterMessageKey`;
- `limit`, максимум `500`;
- `maxBytes`, ограничение суммарного размера raw-блоков пачки.
Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob.
Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю:
- контентные копии и read-receipt по `target_login`;
- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`.
Порядок пачки:
- `created_at_ms ASC`;
- `message_key ASC`.
Курсор хранится локально для пары:
- пользователь;
- удалённый access-сервер.
При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками.
Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob.
При применении событий, полученных через `DmSyncBatch`, сервер:
@@ -599,17 +616,20 @@ UI-следствие для клиента:
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
Синхронизация настроек и DM выполняется одним периодическим процессом и через
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
этого логического сеанса не закрывает физическое соединение с peer. Выборка DM
использует частичный индекс по `synced=false`.
В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`.
В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков.
### 11.4. Ошибки доставки
Если часть серверов временно недоступна:
Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия.
- это не должно отменять локальное принятие уже валидного сообщения;
- повторная доставка может делаться отдельным retry-механизмом;
- повторное получение того же события должно быть безопасным.
Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через `GetDmDeliveryStatus(messageKey)`. Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным.
После неудачной последней попытки устанавливается `failed`; дальнейших автоматических попыток и кнопки ручного повтора нет.
## 12. Хранение в БД
@@ -634,11 +654,13 @@ UI-следствие для клиента:
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Для догоняющей межсерверной синхронизации дополнительно используются:
Изменяемая сетевая часть хранится отдельно:
- индекс по `target_login`, `created_at_ms`, `message_key`;
- отдельные индексы по delete-событиям для `from_login` и `to_login`;
- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`.
- `dm_delivery_state` — состояние и расписание доставки исходящей пары;
- `dm_sync_outbox` — событие владельца и единственный флаг ACK `synced`;
- частичные индексы содержат только due/unsynced строки.
Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется.
## 13. Что обязательно должно измениться в коде относительно v0.5
@@ -651,7 +673,7 @@ UI-следствие для клиента:
- межсерверная маршрутизация DM должна идти через `access_servers`;
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
- логика должна быть безопасна для нескольких серверов у каждой стороны.
- логика должна быть безопасна для одного или двух серверов у каждой стороны.
## 14. Что в v1 пока не входит
@@ -659,4 +681,4 @@ UI-следствие для клиента:
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- межсерверная авторизация `DmSyncBatch`.
- межсерверная авторизация DM-синхронизации и доставки.
+13 -1
View File
@@ -13,6 +13,9 @@
Логика протокола, API и поведение сервера описаны отдельно:
- `docs/Personal_Messages/Протокол_DM_v1.md`
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`
`deliveryState`, retry-времена и sync-флаг не входят в `SHiNE_DM` и не подписываются пользователем. Это изменяемые серверные метаданные, хранящиеся отдельно от raw-контейнера. Для корреляции доставки используется уже существующий `messageKey`; добавление delivery-воркера не меняет ни одного байта формата ниже.
## 1. Общие правила
@@ -252,6 +255,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`
Типы:
@@ -328,4 +340,4 @@ ReadReceiptBody_v1_0
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.
Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

+310 -5
View File
@@ -12,13 +12,13 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260819190000';
window.__SHINE_BUILD_HASH__ = '20260822140000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
(function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';
</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__ = '20260819190000';
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>
+54 -7
View File
@@ -5,7 +5,13 @@ 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 { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
@@ -37,6 +43,7 @@ import {
deleteConversationMessagesBefore,
deleteSignedMessageByBaseKey,
markIncomingReadByBaseKey,
markOutgoingDeliveryState,
markOutgoingReadByBaseKey,
normalizeDmChatId,
setContacts,
@@ -80,17 +87,18 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js';
import * as messagesList from './pages/messages-list.js?v=202608221218';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608191738';
import * as chatView from './pages/chat-view.js?v=202608221218';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelView from './pages/channel-view.js';
import * as channelAboutView from './pages/channel-about-view.js';
import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
import * as networkView from './pages/network-view.js?v=202608221226';
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -148,6 +156,7 @@ const routes = {
user: userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-about-view': channelAboutView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView,
'add-personal-public-chat-view': addPersonalPublicChatView,
@@ -190,12 +199,22 @@ let initialConnectionCompleted = false;
let orientationLockInFlight = false;
let currentChromeCleanup = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'messages-list',
'chat-view',
'channels-list',
'channel-view',
'channel-thread-view',
'notifications-view',
]);
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
'entry-settings-view',
'network-view',
'channels-list',
'channel-view',
'channel-about-view',
'channel-thread-view',
'user',
'contact-search-view',
@@ -208,6 +227,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));
@@ -1128,6 +1151,14 @@ function renderPageFailureFallback(pageId, error) {
refreshConnectionUi();
}
function attachPageScrollToBottom(pageId, screen) {
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
return attachScrollToBottomButton({
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
});
}
function renderApp() {
syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute();
@@ -1164,7 +1195,12 @@ function renderApp() {
}
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
currentCleanup = () => {
pageCleanup?.();
scrollToBottomControl?.cleanup();
};
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
@@ -1358,6 +1394,17 @@ async function init() {
});
});
authService.onEvent('DmDeliveryStateChanged', (evt) => {
const payload = evt?.payload || {};
const changed = markOutgoingDeliveryState({
outgoingKey: payload.outgoingKey,
baseKey: payload.baseKey,
deliveryState: payload.deliveryState,
});
if (!changed) return;
window.dispatchEvent(new CustomEvent('shine-dm-delivery-updated', { detail: payload }));
});
authService.onEvent('SignedMessageArrived', async (evt) => {
const payload = evt?.payload || {};
const messageKey = String(payload.messageKey || '').trim();
+33 -10
View File
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
return (cleanLogin[0] || '?').toUpperCase();
}
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
export function renderAvatar({
initials = '?',
avatar = null,
size = 'large',
className = '',
title = '',
alt = 'Аватар',
glow = false,
} = {}) {
const wrap = document.createElement('div');
const classes = ['avatar', 'avatar-image'];
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
const sizeClass = pickSizeClass(size);
if (sizeClass) classes.push(sizeClass);
if (sizeClass) classes.add(sizeClass);
const extraClass = String(className || '').trim();
if (extraClass) classes.push(...extraClass.split(/\s+/g));
wrap.className = classes.join(' ');
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
if (glow) classes.add('avatar-glow');
wrap.className = Array.from(classes).join(' ');
if (title) wrap.title = String(title);
const fallback = document.createElement('span');
fallback.className = 'avatar-fallback';
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
wrap.append(fallback);
const txId = String(avatar?.ar || '').trim();
@@ -56,7 +57,8 @@ export function renderUserAvatar({
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
const img = document.createElement('img');
img.alt = 'Аватар';
img.className = 'avatar-photo';
img.alt = String(alt || 'Аватар');
img.loading = 'lazy';
img.decoding = 'async';
wrap.append(img);
@@ -131,3 +133,24 @@ export function renderUserAvatar({
return wrap;
}
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
avatar = null,
size = 'large',
className = '',
title = '',
glow = false,
} = {}) {
return renderAvatar({
initials: buildAvatarInitials({ login, firstName, lastName }),
avatar,
size,
className,
title,
alt: 'Аватар',
glow,
});
}
+18 -5
View File
@@ -1,14 +1,20 @@
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
const wrap = document.createElement('header');
wrap.className = 'page-header';
wrap.className = 'page-header app-topbar-shell';
const left = document.createElement('div');
left.className = 'header-left';
if (leftAction) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'icon-btn';
btn.textContent = leftAction.label;
const rawLabel = String(leftAction.label || '').trim();
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '';
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
btn.textContent = isBackAction ? '←' : rawLabel;
if (isBackAction) {
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
btn.title = leftAction.title || 'Назад';
}
btn.addEventListener('click', leftAction.onClick);
left.append(btn);
}
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
left.append(label);
}
const center = document.createElement('div');
center.className = 'header-center';
if (centerNode instanceof Node) {
center.append(centerNode);
} else {
const h1 = document.createElement('h1');
h1.className = 'page-title';
h1.textContent = title;
center.append(h1);
}
const right = document.createElement('div');
right.className = 'header-actions';
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
right.append(btn);
});
wrap.append(left, h1, right);
wrap.append(left, center, right);
return wrap;
}
+10
View File
@@ -0,0 +1,10 @@
export function createOverflowDots({ className = '' } = {}) {
const dots = document.createElement('span');
const extra = String(className || '').trim();
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
dots.setAttribute('aria-hidden', 'true');
for (let i = 0; i < 3; i += 1) {
dots.append(document.createElement('i'));
}
return dots;
}
@@ -0,0 +1,144 @@
function resolveElement(value) {
return typeof value === 'function' ? value() : value;
}
function buildArrowIcon() {
const icon = document.createElement('span');
icon.className = 'scroll-to-bottom-btn__icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '↓';
return icon;
}
export function attachScrollToBottomButton({
scrollContainer,
mountTarget = document.querySelector('.app-shell'),
thresholdPx = 160,
title = 'Вниз',
} = {}) {
const target = resolveElement(mountTarget);
if (!(target instanceof Element)) {
return {
button: null,
refresh() {},
cleanup() {},
};
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'scroll-to-bottom-btn';
button.title = title;
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
button.tabIndex = -1;
button.append(buildArrowIcon());
target.append(button);
let disposed = false;
let boundContainer = null;
let resizeObserver = null;
let mutationObserver = null;
let refreshFrame = 0;
const hide = () => {
button.classList.remove('is-visible');
button.setAttribute('aria-hidden', 'true');
button.tabIndex = -1;
};
const scheduleRefresh = () => {
if (disposed || refreshFrame) return;
refreshFrame = window.requestAnimationFrame(() => {
refreshFrame = 0;
refresh();
});
};
const unbindContainer = () => {
boundContainer?.removeEventListener('scroll', scheduleRefresh);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
resizeObserver = null;
mutationObserver = null;
boundContainer = null;
};
const bindContainer = () => {
const nextContainer = resolveElement(scrollContainer);
if (!(nextContainer instanceof Element)) {
if (boundContainer) unbindContainer();
return null;
}
if (nextContainer === boundContainer) return boundContainer;
unbindContainer();
boundContainer = nextContainer;
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
if (typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(scheduleRefresh);
resizeObserver.observe(boundContainer);
}
if (typeof MutationObserver === 'function') {
mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
}
return boundContainer;
};
function refresh() {
if (disposed) return;
const container = bindContainer();
if (!container) {
hide();
return;
}
const scrollHeight = Number(container.scrollHeight || 0);
const clientHeight = Number(container.clientHeight || 0);
const scrollTop = Number(container.scrollTop || 0);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
button.classList.toggle('is-visible', shouldShow);
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
button.tabIndex = shouldShow ? 0 : -1;
}
const scrollToBottom = () => {
const container = bindContainer();
if (!container) return;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
} else {
container.scrollTop = container.scrollHeight;
}
scheduleRefresh();
};
button.addEventListener('click', scrollToBottom);
window.addEventListener('resize', scheduleRefresh);
window.visualViewport?.addEventListener('resize', scheduleRefresh);
window.requestAnimationFrame(refresh);
return {
button,
refresh: scheduleRefresh,
cleanup() {
if (disposed) return;
disposed = true;
if (refreshFrame) {
window.cancelAnimationFrame(refreshFrame);
refreshFrame = 0;
}
unbindContainer();
window.removeEventListener('resize', scheduleRefresh);
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
button.removeEventListener('click', scrollToBottom);
button.remove();
},
};
}
+10
View File
@@ -0,0 +1,10 @@
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg';
export function createShineConnectionsLogo({ className = '' } = {}) {
const img = document.createElement('img');
img.src = SHINE_CONNECTIONS_LOGO_SRC;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.className = String(className || '').trim();
return img;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
@@ -8,7 +9,7 @@ import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
];
+10 -11
View File
@@ -10,7 +10,7 @@ import {
openArweaveAttachmentManager,
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { renderAvatar } from '../components/avatar-image.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
function renderAvatarPreview(slot, avatar, title) {
if (!slot) return;
slot.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
const label = String(title || 'К').trim() || 'К';
const wrap = renderAvatar({
initials: label.slice(0, 1).toUpperCase() || 'К',
avatar,
size: 'small',
className: 'channel-profile-avatar',
title: label,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import {
readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
@@ -73,13 +74,14 @@ export function render({ navigate }) {
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад"></button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
<button class="text-btn" type="button" data-action="help">Справка</button>
</div>
`;
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error';
+182
View File
@@ -0,0 +1,182 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { navigateBack } from '../router.js';
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
import { showToast } from '../services/channels-ux.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channel-about-view', title: 'О канале' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function normalizeHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
function toSafeInt(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) {
const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase();
const rootNo = Number(channelRootBlockNumber);
const rootHash = normalizeHash(channelRootBlockHash);
const rows = Object.values(state.channelsIndex || {});
return rows.find((row) => (
String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
&& Number(row?.channel?.channelRoot?.blockNumber) === rootNo
&& normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash
)) || null;
}
function buildChannelLink(route) {
if (!route) return '';
const url = new URL(window.location.href);
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
url.hash = '';
return url.toString();
}
function statsText(value) {
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
}
export function render({ navigate, route, chrome }) {
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
const channelRoute = makeShineChannelRootRoute({
ownerBlockchainName,
channelRootBlockNumber,
channelRootBlockHash,
});
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = renderHeader({
title: 'О канале',
leftAction: {
label: '←',
onClick: () => {
if (window.history.length > 1) {
navigateBack();
return;
}
if (channelRoute) navigate(channelRoute);
},
ariaLabel: 'Назад',
title: 'Назад',
},
});
chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack channel-about-card';
card.innerHTML = `
<div class="stack" id="channel-about-content">
<div class="meta-muted">Загрузка данных канала</div>
</div>
`;
const footer = document.createElement('div');
footer.className = 'meta-muted screen-footer';
footer.textContent = 'О канале (channel-about-view)';
screen.append(card, footer);
const renderContent = (channel) => {
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim();
const description = String(channel?.channelDescription || channel?.description || '').trim();
const subscribersCount = Number(channel?.subscribersCount || 0);
const aboutRoute = makeShineChannelRootRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
});
const channelLinkRoute = makeShineChannelShortRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelName: channel?.channelName || '',
});
const channelLink = buildChannelLink(channelLinkRoute);
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
const content = card.querySelector('#channel-about-content');
if (!content) return;
content.innerHTML = `
<div class="channel-profile-modal-head">
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
</div>
<div class="channel-meta-details-grid">
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
</div>
`;
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (!channelLinkRoute) return;
navigate(channelLinkRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
if (!channelLink) return;
try {
await navigator.clipboard.writeText(channelLink);
showToast('Ссылка скопирована');
} catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
}
});
};
const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash);
if (cached?.channel) {
renderContent({
...cached.channel,
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
});
return screen;
}
void (async () => {
try {
const payload = await authService.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber,
channelRootBlockHash,
}, 1, 'asc', String(state.session.login || '').trim());
renderContent(payload?.channel || {});
} catch (error) {
const content = card.querySelector('#channel-about-content');
if (content) {
content.innerHTML = `
<div class="meta-muted">Не удалось загрузить данные канала.</div>
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
`;
}
}
})();
return screen;
}
+22 -23
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [],
});
header.classList.add('channel-thread-topbar');
const headerLeft = header.querySelector('.header-left');
let threadHeaderButton = null;
if (headerLeft) {
const channelsListButton = document.createElement('button');
channelsListButton.type = 'button';
channelsListButton.className = 'icon-btn';
channelsListButton.textContent = '↑';
channelsListButton.title = 'К списку каналов';
channelsListButton.setAttribute('aria-label', 'К списку каналов');
channelsListButton.addEventListener('click', () => navigate('channels-list'));
headerLeft.append(channelsListButton);
threadHeaderButton = document.createElement('button');
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
headerLeft.append(threadHeaderButton);
}
const header = renderHeader({
centerNode: threadHeaderButton,
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header);
const statusBox = document.createElement('div');
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--thread');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
}
+252 -50
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import {
authService,
getMessageReactionState,
@@ -16,7 +16,7 @@ import {
showToast,
softHaptic,
} from '../services/channels-ux.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import {
composeMessageWithAttachments,
@@ -30,8 +30,8 @@ import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
makeShineChannelAboutRoute,
} from '../services/shine-routes.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
@@ -237,9 +237,200 @@ 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;
@@ -387,18 +578,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
}
function createChannelAvatarElement(channel, size = 72) {
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
const txId = String(channel?.avaAr || '').trim();
if (txId) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
wrap.append(img);
} else {
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
const wrap = renderAvatar({
initials: title.slice(0, 1).toUpperCase() || 'К',
avatar: txId ? { ar: txId } : null,
size: 'small',
className: 'channel-profile-avatar',
title,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
return wrap;
}
@@ -1447,6 +1637,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(),
@@ -1749,6 +1940,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;
@@ -1914,6 +2108,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';
@@ -1921,13 +2119,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(reverseWarning);
}
if (Number(channelData.unreadCount || 0) > 0) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
screen.append(unreadLine);
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
@@ -1946,6 +2137,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',
@@ -1967,6 +2159,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;
@@ -2018,10 +2217,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
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) {
@@ -2056,19 +2270,20 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = '';
};
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
const header = renderHeader({
title: '',
centerNode: channelHeaderButton,
leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
],
});
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
if (channelHeaderButton) {
channelHeaderButton.disabled = true;
}
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
@@ -2079,6 +2294,7 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
};
let activeSelector = null;
@@ -2317,19 +2533,6 @@ export function render({ navigate, route, chrome }) {
try {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
void authService.upsertUserSetting({
login: state.session.login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: lastSeenCount,
storagePwd: state.session.storagePwdInMemory,
}).catch(() => {});
}
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
@@ -2348,13 +2551,12 @@ export function render({ navigate, route, chrome }) {
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
onEdit: () => openEditChannelModal({
channel: apiData.channel,
onSave: onEditChannelMeta,
}),
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
if (aboutRoute) navigate(aboutRoute);
};
}
if (channelEntrypointButton) {
+27 -91
View File
@@ -1,4 +1,4 @@
import { channels as mockChannels } from '../mock-data.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js';
@@ -12,8 +12,9 @@ import {
writeChannelNotificationsState,
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -64,10 +65,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
return makeShineChannelRoute({
ownerLogin,
return makeShineChannelShortRoute({
ownerBlockchainName: ownerBch,
channelName: channelName || fallbackId,
channelName: channelName || fallbackId || ownerLogin,
});
}
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
return first.toUpperCase();
}
function createChannelAvatar(channel = {}) {
return renderAvatar({
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'small',
title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала',
});
}
function allFeedSummaries() {
const feed = state.channelsFeed || {};
return [
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
const row = document.createElement('article');
row.className = 'channel-row';
row.innerHTML = `
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
<div class="channel-row-main">
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
<span class="channel-row-time"></span>
</div>
`;
row.prepend(createChannelAvatar(channel));
row.addEventListener('click', () => {
const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onSubscribeChannel,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 320;
const estimatedHeight = 250;
let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
menu.style.width = `${Math.round(menuWidth)}px`;
const items = [
{ label: 'Поиск', action: () => onFindChannel?.() },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
{ divider: true },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
];
items.forEach((item) => {
@@ -1206,20 +1213,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible);
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (channel.avaAr) {
const img = document.createElement('img');
img.alt = '';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
avatar.append(img);
} else {
avatar.textContent = channel.avatar;
}
const avatar = createChannelAvatar(channel);
const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
@@ -1230,37 +1227,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const count = document.createElement('span');
count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0);
count.hidden = unreadCount <= 0;
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger';
menuButton.textContent = '…';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(menuButton);
listState.revealedCounters.add(channel.id);
if (listState.openMenuId === channel.id) {
closeChannelMenu(listState);
rerenderList();
return;
}
listState.openMenuId = channel.id;
openChannelMenu({
listState,
channel,
anchorEl: menuButton,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
rerenderList,
});
rerenderList();
});
controls.append(menuButton);
}
controls.append(time, count);
row.append(avatar, main, controls);
@@ -1373,43 +1343,18 @@ export function render({ navigate, route, chrome }) {
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.className = 'icon-btn channels-top-back-btn';
backBtn.textContent = '←';
backBtn.setAttribute('aria-label', 'Назад');
backBtn.addEventListener('click', () => navigateBack());
const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title';
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
const findChannelBtn = document.createElement('button');
findChannelBtn.type = 'button';
findChannelBtn.className = 'icon-btn channels-top-search-btn';
findChannelBtn.setAttribute('aria-label', 'Найти канал');
findChannelBtn.title = 'Найти канал';
const findChannelIcon = document.createElement('span');
findChannelIcon.className = 'channels-search-icon';
findChannelIcon.setAttribute('aria-hidden', 'true');
findChannelBtn.append(findChannelIcon);
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn';
createInMyBtn.textContent = '+';
createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.textContent = '⋮';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
@@ -1418,18 +1363,11 @@ export function render({ navigate, route, chrome }) {
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
onSubscribeChannel: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Добавить канал',
submitLabel: 'Добавить',
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
}),
});
});
topBarLeft.append(backBtn, topTitle);
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
topBarEl.append(topBarLeft, topBarRight);
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@@ -1450,10 +1388,8 @@ export function render({ navigate, route, chrome }) {
});
topTitle.textContent = channelsViewTitle(listState.viewMode);
findChannelBtn.style.display = '';
createInMyBtn.style.display = '';
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
+127 -18
View File
@@ -1,4 +1,7 @@
import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
@@ -27,10 +30,79 @@ import {
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function createChatHeaderParts(login, navigate) {
const cleanLogin = String(login || '').trim() || 'unknown';
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
login: cleanLogin,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
const avatarButton = document.createElement('button');
avatarButton.type = 'button';
avatarButton.className = 'chat-header-avatar-btn';
avatarButton.title = `Профиль ${cleanLogin}`;
avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`);
avatarButton.append(avatarSlot);
const loginEl = document.createElement('span');
loginEl.className = 'chat-header-login';
loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.textContent = cleanLogin;
void loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
if (!avatarSlot.isConnected) return;
const upgradedAvatar = renderUserAvatar({
login: cleanLogin,
firstName: String(snapshot?.firstName || '').trim(),
lastName: String(snapshot?.lastName || '').trim(),
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
})
.catch(() => {});
const connectionsButton = document.createElement('button');
connectionsButton.type = 'button';
connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn';
connectionsButton.title = `Связи ${cleanLogin}`;
connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`);
connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' }));
connectionsButton.addEventListener('click', () => {
if (!cleanLogin || cleanLogin === 'unknown') return;
navigate(makeProfileLinksRoute(cleanLogin));
});
avatarButton.addEventListener('click', () => {
if (!cleanLogin || cleanLogin === 'unknown') return;
navigate(makeProfileRoute(cleanLogin));
});
return { centerNode: loginEl, avatarButton, connectionsButton };
}
function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
@@ -246,7 +318,6 @@ function openChatActionsMenu({
anchorY = 0,
onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
@@ -258,8 +329,7 @@ function openChatActionsMenu({
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
@@ -311,10 +381,6 @@ function openChatActionsMenu({
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
close();
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -481,10 +547,28 @@ function resolveEffectiveReadState(messages, msg) {
function resolveDeliveryStatus(messages, msg) {
if (msg?.from !== 'out') return '';
if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓';
if (msg?.firstTick) return '';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
if (deliveryState === 'delivered') return '✓';
if (deliveryState === 'failed') return '!';
if (deliveryState === 'accepted' || msg?.firstTick) return '✓';
return '…';
}
function resolveDeliveryTone(messages, msg) {
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
if (deliveryState === 'delivered') return 'delivered';
if (deliveryState === 'failed') return 'failed';
return 'accepted';
}
function resolveDeliveryNote(msg) {
if (msg?.from !== 'out') return '';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
if (deliveryState === 'failed') return 'Сообщение не доставлено.';
return '';
}
function resolveMessageEditedTimeMs(msg) {
const revisionTimeMs = Number(msg?.revisionTimeMs || 0);
if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0;
@@ -697,13 +781,21 @@ function renderLog(
const status = resolveDeliveryStatus(messages, msg);
if (status) {
const statusNode = document.createElement('span');
statusNode.className = 'bubble-status';
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
statusNode.textContent = status;
metaNode.append(statusNode);
}
bubble.append(metaNode);
const deliveryNote = resolveDeliveryNote(msg);
if (deliveryNote) {
const deliveryNoteNode = document.createElement('div');
deliveryNoteNode.className = `bubble-delivery-note${String(msg?.deliveryState || '') === 'failed' ? ' bubble-delivery-note--failed' : ''}`;
deliveryNoteNode.textContent = deliveryNote;
bubble.append(deliveryNoteNode);
}
const editedAtMs = resolveMessageEditedTimeMs(msg);
if (editedAtMs > 0) {
const editedNode = document.createElement('div');
@@ -784,6 +876,7 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
readAtMs: Number(item?.readAtMs || 0),
rawBlobB64: blobB64,
revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0),
deliveryState: String(item?.deliveryState || ''),
});
} catch (error) {
addAppLogEntry({
@@ -906,7 +999,6 @@ export function render({ navigate, route, chrome }) {
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader';
historyLoader.hidden = true;
@@ -920,9 +1012,9 @@ export function render({ navigate, route, chrome }) {
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
chrome?.setTopbar(
renderHeader({
title: `Чат с ${contact.name}`,
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
@@ -933,7 +1025,7 @@ export function render({ navigate, route, chrome }) {
onClick: () => handleStartCall('audio'),
},
{
label: '⋮',
iconNode: createOverflowDots(),
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -943,7 +1035,6 @@ export function render({ navigate, route, chrome }) {
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -990,8 +1081,10 @@ export function render({ navigate, route, chrome }) {
},
},
],
}),
);
});
const chatHeaderLeft = chatHeader.querySelector('.header-left');
chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton);
chrome?.setTopbar(chatHeader);
if (!isKnownContact) {
const card = document.createElement('div');
@@ -1201,7 +1294,12 @@ export function render({ navigate, route, chrome }) {
focusInputToEnd();
};
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
const applyLocalRevision = async ({
localOutgoingBlobB64,
fallbackMessageKey = '',
fallbackBaseKey = '',
deliveryState = '',
}) => {
if (!localOutgoingBlobB64) return;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
@@ -1224,6 +1322,7 @@ export function render({ navigate, route, chrome }) {
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deliveryState,
deleted: Boolean(parsed?.deleted),
});
return true;
@@ -1299,6 +1398,7 @@ export function render({ navigate, route, chrome }) {
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
}
@@ -1306,6 +1406,7 @@ export function render({ navigate, route, chrome }) {
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
if (editing) {
@@ -1533,7 +1634,14 @@ export function render({ navigate, route, chrome }) {
void sendReadReceiptsForVisible(chatId);
};
const handleDeliveryRefresh = () => {
preserveComposerSelection(input, () => {
renderChatLog({ scrollMode: 'preserve', markAsRead: false });
});
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.addEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
wrap.append(historyLoader, log);
screen.append(wrap);
@@ -1562,6 +1670,7 @@ export function render({ navigate, route, chrome }) {
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
+157 -83
View File
@@ -1,20 +1,30 @@
import { directMessages } from '../mock-data.js';
import {
getChatMessages,
authService,
isSessionInvalidError,
normalizeDmChatId,
setContacts,
state,
terminateCurrentSession,
} from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createShineConnectionsLogo } from '../components/shine-logo.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 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>';
const RELATION_ORDER = new Map([
['close_friend', 0],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
@@ -37,13 +47,14 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
function createDmAvatar(login) {
function createDmAvatar(login, { className = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
title,
className,
});
if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
@@ -58,6 +69,7 @@ function createDmAvatar(login) {
: null,
size: 'small',
title,
className,
});
upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded);
@@ -65,10 +77,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) {
@@ -83,51 +157,44 @@ function formatChatRowTime(ts) {
}
function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0);
const timeA = Number(a?.lastMessageTimeMs || 0);
const timeB = Number(b?.lastMessageTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase();
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 }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand">
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name"></span>
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<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>
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
<span>Поиск пользователей</span>
</button>
</div>
</div>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-logo-wrap')?.append(
createShineConnectionsLogo({ className: 'dm-head-logo' }),
);
const menuButton = head.querySelector('.dm-head-menu-btn');
menuButton?.append(createOverflowDots());
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
@@ -164,7 +231,7 @@ export function render({ navigate, chrome }) {
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
<span>Поиск пользователей</span>
</button>
`;
@@ -206,16 +273,17 @@ export function render({ navigate, chrome }) {
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
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';
@@ -224,14 +292,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>
@@ -239,11 +307,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;
}
@@ -257,62 +329,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,
lastTimeMs,
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 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,
lastTimeMs,
};
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 rows = [...contactRows, ...extraRows].sort(compareChatRows);
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 = '';
@@ -352,7 +426,7 @@ export function render({ navigate, chrome }) {
}
chrome?.setTopbar(head);
screen.append(divider, list);
screen.append(list);
loadList();
screen.cleanup = () => {
+8 -24
View File
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
let persistedCenterLogin = '';
let persistedCenterHistory = [];
export function render({ navigate, route }) {
export function render({ navigate, route, chrome } = {}) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) {
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
else window.history.replaceState({}, '', nextPath);
}
function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0;
}
function openSearchModal() {
const root = document.getElementById('modal-root');
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
persistHistory();
setBackButtonState(backBtnEl);
} catch (error) {
if (requestId !== loadSeq) return;
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
const header = renderHeader({
title: 'Связи',
leftAction: {
label: '←',
onClick: () => {
if (!centerHistory.length) return;
const prev = centerHistory.pop();
if (!prev) {
setBackButtonState(backBtnEl);
return;
}
void load(prev, { pushHistory: false });
},
},
rightActions: [
{ label: 'Найти', onClick: openSearchModal },
],
});
const backBtnEl = header.querySelector('.header-left .icon-btn');
setBackButtonState(backBtnEl);
// «Связи» используют тот же общий topbar, что и остальные страницы.
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
header.classList.add('network-topbar');
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
window.setTimeout(() => openSearchModal(), 0);
}
}
setBackButtonState(backBtnEl);
// Панель фильтров слоёв (оверлей под шапкой)
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
filterBar.className = 'fg-filter-bar app-top-tabs';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
filterBar.append(chip);
});
header.classList.add('network-header-overlay');
stage.append(board, header, filterBar);
chrome?.setTopbar(header);
stage.append(board, filterBar);
screen.append(stage);
return screen;
}
+40 -10
View File
@@ -134,7 +134,7 @@ async function enrichItem(item, activeTab) {
function renderEmpty(activeTab) {
const card = document.createElement('article');
card.className = 'card stack';
card.className = 'card stack notification-empty-state';
const title = document.createElement('strong');
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
const text = document.createElement('p');
@@ -304,10 +304,22 @@ export function render({ navigate, chrome } = {}) {
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'tabs';
tabs.className = 'notification-feed-tabs app-top-tabs';
tabs.innerHTML = `
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
<button
type="button"
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
data-tab="replies"
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
>Ответы</button>
<button
type="button"
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
data-tab="events"
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
>События</button>
`;
const list = document.createElement('div');
@@ -347,13 +359,31 @@ export function render({ navigate, chrome } = {}) {
}
}
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
function setActiveNotificationTab(nextTab) {
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
state.notificationsTab = normalizedTab;
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
const selected = node.dataset.tab === normalizedTab;
node.classList.toggle('is-active', selected);
node.dataset.selected = selected ? 'true' : 'false';
node.setAttribute('aria-selected', selected ? 'true' : 'false');
});
}
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
setActiveNotificationTab(state.notificationsTab);
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const nextTab = String(btn.dataset.tab || 'replies');
if (state.notificationsTab === nextTab) return;
state.notificationsTab = nextTab;
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
btn.classList.add('active');
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
if (state.notificationsTab === nextTab) {
setActiveNotificationTab(nextTab);
return;
}
setActiveNotificationTab(nextTab);
void load();
});
});
+124 -16
View File
@@ -1,5 +1,5 @@
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
@@ -7,6 +7,7 @@ import {
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = `
<div class="header-actions profile-top-actions">
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
<div class="header-left" aria-hidden="true"></div>
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
</button>
</div>
`;
const topActions = topbar.querySelector('.profile-top-actions');
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
profileMenuButton?.append(createOverflowDots());
let profileMenuPortal = null;
const closeProfileMenu = () => {
profileMenuPortal?.remove();
profileMenuPortal = null;
profileMenuButton?.setAttribute('aria-expanded', 'false');
profileMenuWrap?.classList.remove('is-open');
};
const positionProfileMenu = () => {
if (!profileMenuPortal || !profileMenuButton) return;
const rect = profileMenuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = profileMenuPortal.offsetWidth || 224;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
profileMenuPortal.style.left = `${Math.round(left)}px`;
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openProfileMenu = () => {
if (!profileMenuButton || profileMenuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Редактировать профиль</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
<span>Кошелёк</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
<span>Настройки</span>
</button>
`;
const goTo = (route) => {
closeProfileMenu();
navigate(route);
};
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
profileMenuPortal = portal;
profileMenuButton.setAttribute('aria-expanded', 'true');
profileMenuWrap?.classList.add('is-open');
positionProfileMenu();
};
profileMenuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (profileMenuPortal) closeProfileMenu();
else openProfileMenu();
});
document.addEventListener('click', (event) => {
if (!profileMenuPortal) return;
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
closeProfileMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !profileMenuPortal) return;
closeProfileMenu();
profileMenuButton?.focus();
});
window.addEventListener('resize', positionProfileMenu, { passive: true });
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
chrome?.setTopbar(topbar);
const card = document.createElement('div');
@@ -152,6 +223,12 @@ export function render({ navigate, chrome }) {
let currentToggles = [];
let currentGender = 'unknown';
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
let currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
function syncIdentity() {
if (!identityEl) return;
@@ -198,6 +275,21 @@ export function render({ navigate, chrome }) {
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
}
function renderStats() {
const stats = [
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
];
stats.forEach((stat) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
listWrap.append(row);
});
}
officialBtn?.classList.add('profile-badge-trigger');
shineBtn?.classList.add('profile-badge-trigger');
officialBtn?.addEventListener('click', () => {
@@ -215,6 +307,7 @@ export function render({ navigate, chrome }) {
function renderFields(fields) {
listWrap.innerHTML = '';
renderStats();
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
@@ -246,6 +339,12 @@ export function render({ navigate, chrome }) {
];
currentGender = 'unknown';
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
@@ -254,11 +353,20 @@ export function render({ navigate, chrome }) {
}
try {
const snapshot = await loadProfileSnapshot(login);
const [snapshot, user] = await Promise.all([
loadProfileSnapshot(login),
authService.getUser(login).catch(() => ({})),
]);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
currentGender = snapshot.gender || 'unknown';
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingUsersCount: Number(user?.followingUsersCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
+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,

Some files were not shown because too many files have changed in this diff Show More