SHA256
Compare commits
17
Commits
7a5ac01d1c
...
d8e0c77951
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
d8e0c77951 | ||
|
|
015fade4c0 | ||
|
|
da14b51e80 | ||
|
|
0f30317bb4 | ||
|
|
1f1bc0a7b9 | ||
|
|
60c8a6608a | ||
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
d3e6aa2be2 | ||
|
|
19362b950a | ||
|
|
23bdadab56 |
@@ -98,14 +98,9 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@ import java.util.Objects;
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|
||||
@@ -28,6 +28,8 @@ public final class DatabaseInitializer {
|
||||
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 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";
|
||||
@@ -39,6 +41,8 @@ public final class DatabaseInitializer {
|
||||
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";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -57,10 +61,8 @@ public final class DatabaseInitializer {
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
@@ -142,6 +144,15 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
|
||||
private static volatile DbController instance;
|
||||
|
||||
private final PostgresDbController delegate;
|
||||
private volatile boolean dmDialogStateBootstrapped;
|
||||
|
||||
private DbController() {
|
||||
this.delegate = PostgresDbController.getInstance();
|
||||
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
|
||||
if (instance == null) {
|
||||
synchronized (DbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new DbController();
|
||||
DbController created = new DbController();
|
||||
instance = created;
|
||||
created.bootstrapDmDialogStateIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
private void bootstrapDmDialogStateIfNeeded() {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
synchronized (this) {
|
||||
if (dmDialogStateBootstrapped) return;
|
||||
try (Connection connection = delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
dmDialogStateBootstrapped = true;
|
||||
} catch (SQLException e) {
|
||||
instance = null;
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,23 +58,17 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package shine.db;
|
||||
|
||||
import shine.db.connection.DriverManagerDbProvider;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -35,12 +34,6 @@ public final class PostgresDbController {
|
||||
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
||||
connection.setAutoCommit(true);
|
||||
});
|
||||
|
||||
try (Connection connection = this.delegate.getConnection()) {
|
||||
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PostgresDbController getInstance() {
|
||||
|
||||
+6
-6
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
SET likes_count = GREATEST(
|
||||
0,
|
||||
likes_count - (
|
||||
SELECT COUNT(*)
|
||||
likes_count - COALESCE((
|
||||
SELECT COUNT(*)::int
|
||||
FROM reactions_state rs
|
||||
WHERE rs.from_bch_name = ?
|
||||
AND rs.reaction_type = ?
|
||||
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
AND rs.to_block_number = message_stats.to_block_number
|
||||
AND rs.to_block_hash = message_stats.to_block_hash
|
||||
AND rs.to_bch_name <> ?
|
||||
)
|
||||
), 0)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
|
||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE message_stats
|
||||
SET replies_count = MAX(
|
||||
SET replies_count = GREATEST(
|
||||
0,
|
||||
replies_count - COALESCE((
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)::int
|
||||
FROM blocks b
|
||||
WHERE b.bch_name = ?
|
||||
AND b.msg_type = 1
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class UserNotificationsStateDAO {
|
||||
private static volatile UserNotificationsStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserNotificationsStateDAO() {}
|
||||
|
||||
public static UserNotificationsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserNotificationsStateDAO.class) {
|
||||
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO user_notifications_state (
|
||||
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||
created_at_ms = EXCLUDED.created_at_ms,
|
||||
source_login = EXCLUDED.source_login,
|
||||
target_login = EXCLUDED.target_login,
|
||||
target_bch_name = EXCLUDED.target_bch_name,
|
||||
target_block_number = EXCLUDED.target_block_number,
|
||||
target_block_hash = EXCLUDED.target_block_hash,
|
||||
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||
source_text = EXCLUDED.source_text
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, e.getOwnerLogin());
|
||||
ps.setString(i++, e.getNotificationKind());
|
||||
ps.setLong(i++, e.getCreatedAtMs());
|
||||
ps.setString(i++, e.getSourceLogin());
|
||||
ps.setString(i++, e.getSourceBchName());
|
||||
ps.setInt(i++, e.getSourceBlockNumber());
|
||||
ps.setBytes(i++, e.getSourceBlockHash());
|
||||
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||
ps.setInt(i++, e.getSourceMsgSubType());
|
||||
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, kind);
|
||||
ps.setInt(3, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setNotificationKind(rs.getString("notification_kind"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceLogin(rs.getString("source_login"));
|
||||
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||
e.setSourceText(rs.getString("source_text"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+84
@@ -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; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class UserNotificationEntry {
|
||||
private String ownerLogin;
|
||||
private String notificationKind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBchName;
|
||||
private int sourceBlockNumber;
|
||||
private byte[] sourceBlockHash;
|
||||
private String targetLogin;
|
||||
private String targetBchName;
|
||||
private Integer targetBlockNumber;
|
||||
private byte[] targetBlockHash;
|
||||
private int sourceMsgSubType;
|
||||
private String sourceText;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getNotificationKind() { return notificationKind; }
|
||||
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBchName() { return sourceBchName; }
|
||||
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBchName() { return targetBchName; }
|
||||
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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, 11, 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;
|
||||
@@ -808,6 +808,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,
|
||||
|
||||
+8
@@ -88,14 +88,17 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_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;
|
||||
@@ -110,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;
|
||||
@@ -207,6 +211,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -217,6 +222,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()),
|
||||
@@ -296,6 +302,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
@@ -306,6 +313,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),
|
||||
|
||||
+69
-2
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
import org.slf4j.Logger;
|
||||
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(
|
||||
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return b;
|
||||
}
|
||||
|
||||
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
|
||||
if (block == null || storedEntry == null) return null;
|
||||
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
long createdAtMs = block.timestamp * 1000L;
|
||||
String ownerLogin = storedEntry.getToLogin();
|
||||
String sourceLogin = storedEntry.getLogin();
|
||||
|
||||
// A user must never receive a notification for replying to their own message/thread.
|
||||
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
|
||||
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
&& block.body instanceof TextReplyBody replyBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("reply");
|
||||
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
|
||||
String ownerLogin,
|
||||
long createdAtMs,
|
||||
int msgSubType) {
|
||||
UserNotificationEntry entry = new UserNotificationEntry();
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setCreatedAtMs(createdAtMs);
|
||||
entry.setSourceLogin(storedEntry.getLogin());
|
||||
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
|
||||
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
|
||||
entry.setSourceBlockHash(storedEntry.getBlockHash());
|
||||
entry.setTargetLogin(storedEntry.getToLogin());
|
||||
entry.setTargetBchName(storedEntry.getToBchName());
|
||||
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
|
||||
entry.setTargetBlockHash(storedEntry.getToBlockHash());
|
||||
entry.setSourceMsgSubType(msgSubType);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String blockchainNameFromEntry(BlockEntry entry) {
|
||||
return entry == null ? null : entry.getBchName();
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
|
||||
+14
-2
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
ChannelNameStateDAO channelNameStateDAO,
|
||||
UserNotificationsStateDAO userNotificationsStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelMetaUpdateEntry,
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
public static class NotificationItem {
|
||||
private String kind;
|
||||
private long createdAtMs;
|
||||
private String sourceLogin;
|
||||
private String sourceBlockchainName;
|
||||
private int sourceBlockNumber;
|
||||
private String sourceBlockHash;
|
||||
private Integer sourceMsgSubType;
|
||||
private Integer connectionTypeCode;
|
||||
private String sourceText;
|
||||
private String targetLogin;
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceLogin() { return sourceLogin; }
|
||||
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||
public String getSourceBlockHash() { return sourceBlockHash; }
|
||||
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
|
||||
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||
public Integer getConnectionTypeCode() { return connectionTypeCode; }
|
||||
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
|
||||
public String getSourceText() { return sourceText; }
|
||||
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-24
@@ -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()) {
|
||||
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.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()));
|
||||
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);
|
||||
resp.setNextStoredAtMs(entry.getCreatedAtMs());
|
||||
resp.setNextMessageKey(entry.getMessageKey());
|
||||
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;
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
+23
-5
@@ -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());
|
||||
|
||||
+54
-8
@@ -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,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
|
||||
SignedMessageEntry incomingEntry;
|
||||
SignedMessageEntry outgoingEntry;
|
||||
boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp())
|
||||
|| !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 +86,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 +144,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;
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-24
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetDmDeliveryStatus_Request extends Net_Request {
|
||||
private String messageKey;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String value) { this.messageKey = value; }
|
||||
}
|
||||
+16
@@ -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; }
|
||||
}
|
||||
+3
@@ -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; }
|
||||
}
|
||||
|
||||
+3
@@ -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; }
|
||||
}
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
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(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.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.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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
-49
@@ -17,13 +17,19 @@ 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 {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
@@ -40,7 +46,11 @@ 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 {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
@@ -55,17 +65,54 @@ public final class RemoteDmSyncClient {
|
||||
ensureOk("ReceiveIncomingMessage", response);
|
||||
}
|
||||
|
||||
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
|
||||
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
|
||||
JsonNode response = send(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 {
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(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 +121,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 +134,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(
|
||||
@@ -106,9 +161,7 @@ public final class RemoteDmSyncClient {
|
||||
{
|
||||
"op":"DeleteMessage",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"blobB64":%s
|
||||
}
|
||||
"payload":{"blobB64":%s}
|
||||
}
|
||||
""".formatted("%s", blobJson));
|
||||
ensureOk("DeleteMessage", response);
|
||||
@@ -120,9 +173,7 @@ public final class RemoteDmSyncClient {
|
||||
{
|
||||
"op":"DeleteConversation",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"blobB64":%s
|
||||
}
|
||||
"payload":{"blobB64":%s}
|
||||
}
|
||||
""".formatted("%s", blobJson));
|
||||
ensureOk("DeleteConversation", response);
|
||||
@@ -132,24 +183,19 @@ public final class RemoteDmSyncClient {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -157,9 +203,7 @@ public final class RemoteDmSyncClient {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -172,27 +216,14 @@ public final class RemoteDmSyncClient {
|
||||
}
|
||||
|
||||
private static void tryAbort(WebSocket webSocket) {
|
||||
try {
|
||||
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
webSocket.abort();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
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 {
|
||||
@@ -214,9 +245,7 @@ public final class RemoteDmSyncClient {
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
textBuffer.append(data);
|
||||
if (last && !responseFuture.isDone()) {
|
||||
responseFuture.complete(textBuffer.toString());
|
||||
}
|
||||
if (last && !responseFuture.isDone()) responseFuture.complete(textBuffer.toString());
|
||||
webSocket.request(1);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
@@ -230,16 +259,15 @@ public final class RemoteDmSyncClient {
|
||||
@Override
|
||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||
if (!responseFuture.isDone()) {
|
||||
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
|
||||
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);
|
||||
}
|
||||
if (!responseFuture.isDone()) responseFuture.completeExceptionally(error);
|
||||
openLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */
|
||||
public final class RemoteSyncSession implements AutoCloseable {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6)).build();
|
||||
private final LinkedBlockingQueue<String> responses = new LinkedBlockingQueue<>();
|
||||
private final WebSocket webSocket;
|
||||
|
||||
public RemoteSyncSession(String serverAddressRaw) throws Exception {
|
||||
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
|
||||
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
|
||||
Listener listener = new Listener(responses);
|
||||
webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6))
|
||||
.buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public synchronized JsonNode send(String jsonTemplate) throws Exception {
|
||||
String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID());
|
||||
webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS);
|
||||
String json = responses.poll(12, TimeUnit.SECONDS);
|
||||
if (json == null) throw new TimeoutException("WS response timeout");
|
||||
return MAPPER.readTree(json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static final class Listener implements WebSocket.Listener {
|
||||
private final LinkedBlockingQueue<String> responses;
|
||||
private final StringBuilder text = new StringBuilder();
|
||||
|
||||
private Listener(LinkedBlockingQueue<String> responses) { this.responses = responses; }
|
||||
@Override public void onOpen(WebSocket ws) { ws.request(1); }
|
||||
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
|
||||
text.append(data);
|
||||
if (last) {
|
||||
responses.offer(text.toString());
|
||||
text.setLength(0);
|
||||
}
|
||||
ws.request(1);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
@Override public CompletionStage<?> onBinary(WebSocket ws, ByteBuffer data, boolean last) {
|
||||
ws.request(1);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
@Override public void onError(WebSocket ws, Throwable error) {
|
||||
responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -24,7 +24,13 @@ public final class RemoteUserSettingsSyncClient {
|
||||
.build();
|
||||
|
||||
public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(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 +69,20 @@ public final class RemoteUserSettingsSyncClient {
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
try (RemoteSyncSession session = new RemoteSyncSession(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,
|
||||
|
||||
@@ -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.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) {}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ 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.info.url=
|
||||
server.info.physicalRegion=
|
||||
server.info.description=
|
||||
|
||||
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: FRIEND", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: FRIEND", t);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# TODO: уведомления и full-resync после фиксации блоков в Arweave
|
||||
|
||||
## Контекст
|
||||
|
||||
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
|
||||
|
||||
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
|
||||
|
||||
## Что проверить после внедрения Arweave
|
||||
|
||||
Когда схема Arweave и правила восстановления цепочки стабилизируются:
|
||||
|
||||
- определить окончательный source of truth для accepted/finalized blocks;
|
||||
- проверить поведение `user_notifications_state` при rollback/full-resync;
|
||||
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
|
||||
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
|
||||
- добавить интеграционный тест на divergence + full-resync + notification projection.
|
||||
|
||||
## Важно
|
||||
|
||||
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Build a source bundle ZIP while excluding credentials, private keys,
|
||||
# local state, generated artifacts and other likely secrets.
|
||||
#
|
||||
# Usage:
|
||||
# ./bundle.sh
|
||||
# ./bundle.sh path/to/output.zip
|
||||
#
|
||||
# Run from anywhere inside the project; the script resolves its own directory.
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
||||
case "$OUT" in
|
||||
/*) ;;
|
||||
*) OUT="$ROOT/$OUT" ;;
|
||||
esac
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
echo "ERROR: 'zip' is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
LIST="$TMP/files.txt"
|
||||
SAFE_LIST="$TMP/safe-files.txt"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Paths / filenames that must never be bundled.
|
||||
is_denied_path() {
|
||||
local p="/$1"
|
||||
|
||||
case "$p" in
|
||||
*/.git/*|*/.git|\
|
||||
*/.gradle/*|*/.gradle|\
|
||||
*/.gradle-home/*|*/.gradle-home|\
|
||||
*/.idea/*|*/.idea|\
|
||||
*/.vscode/*|*/.vscode|\
|
||||
*/node_modules/*|*/node_modules|\
|
||||
*/target/*|*/target|\
|
||||
*/build/*|*/build|\
|
||||
*/out/*|*/out|\
|
||||
*/bin/*|*/bin|\
|
||||
*/logs/*|*/logs|\
|
||||
*/data/*|*/data|\
|
||||
*/test-ledger/*|*/test-ledger|\
|
||||
*/.anchor/*|*/.anchor|\
|
||||
*/.yarn/*|*/.yarn|\
|
||||
*/.vendor/*|*/.vendor|\
|
||||
*/.agents/*|*/.agents|\
|
||||
*/.codex/*|*/.codex|\
|
||||
*/.claude/*|*/.claude|\
|
||||
*/deploy/backup/archive/*|\
|
||||
*/scripts/*/runs/*|\
|
||||
*/scripts/*/keypairs/*|\
|
||||
*/keys/*|\
|
||||
*/.git-local-backup/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
local base="${p##*/}"
|
||||
local lower
|
||||
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$lower" in
|
||||
.env|.env.*|\
|
||||
.debug-token|\
|
||||
.npmrc|.pypirc|.netrc|\
|
||||
credentials|credentials.*|\
|
||||
secrets|secrets.*|\
|
||||
secret|secret.*|\
|
||||
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||
*keypair*.json|\
|
||||
service-account*.json|\
|
||||
firebase-adminsdk*.json|\
|
||||
google-services.json|\
|
||||
validator.log)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$lower" in
|
||||
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||
.ds_store)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||
else
|
||||
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||
fi
|
||||
|
||||
# Convert to project-relative paths and enforce hard deny rules.
|
||||
: > "$LIST"
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ "$f" = /* ]]; then
|
||||
rel="${f#"$ROOT"/}"
|
||||
else
|
||||
rel="$f"
|
||||
fi
|
||||
|
||||
[[ "$rel" == "$OUT" ]] && continue
|
||||
[[ -z "$rel" ]] && continue
|
||||
|
||||
if is_denied_path "$rel"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$LIST"
|
||||
done < "$TMP/files.z"
|
||||
|
||||
sort -u "$LIST" -o "$LIST"
|
||||
|
||||
# Content scan: fail closed on common credential/private-key patterns.
|
||||
# We scan only text-ish files; grep -I skips binary data.
|
||||
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||
|
||||
: > "$SAFE_LIST"
|
||||
found_secret=0
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -f "$ROOT/$rel" ]] || continue
|
||||
|
||||
# Files that contain examples/templates can legitimately mention secret keys
|
||||
# with placeholders. They are scanned too, but placeholder-looking values
|
||||
# are less likely to match the regex above.
|
||||
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||
echo "BLOCKED: possible secret in $rel" >&2
|
||||
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||
| head -n 3 >&2 || true
|
||||
found_secret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||
done < "$LIST"
|
||||
|
||||
if (( found_secret != 0 )); then
|
||||
echo >&2
|
||||
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||
echo "ERROR: no files left to bundle." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
rm -f -- "$OUT"
|
||||
|
||||
(
|
||||
cd "$ROOT"
|
||||
zip -q -9 "$OUT" -@ < "$SAFE_LIST"
|
||||
)
|
||||
|
||||
echo "Created: $OUT"
|
||||
echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')"
|
||||
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||
@@ -58,14 +58,16 @@
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
| `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` | постраничная загрузка истории личного диалога |
|
||||
@@ -75,7 +77,8 @@
|
||||
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
|
||||
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||
|
||||
@@ -107,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`
|
||||
@@ -147,7 +152,9 @@
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда.
|
||||
|
||||
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
|
||||
|
||||
### Примечание
|
||||
|
||||
@@ -243,6 +250,7 @@
|
||||
"reencryptedAtMs": 0,
|
||||
"createdAtMs": 1774700001123,
|
||||
"readAtMs": 1774700001456,
|
||||
"deliveryState": "delivered",
|
||||
"blobB64": "BASE64_SIGNED_BLOCK"
|
||||
}
|
||||
]
|
||||
@@ -252,11 +260,50 @@
|
||||
|
||||
Для следующей страницы клиент должен передать `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": "ReceiveOutcomingMessage",
|
||||
"requestId": "dm-peer-001",
|
||||
"payload": {
|
||||
"incomingBlobB64": "BASE64_INCOMING",
|
||||
"outgoingBlobB64": "BASE64_OUTGOING",
|
||||
"sourceServerLogin": "server-a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ имеет прежний формат `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
|
||||
{
|
||||
@@ -264,55 +311,58 @@
|
||||
"requestId": "dm-sync-001",
|
||||
"payload": {
|
||||
"ownerLogin": "alice",
|
||||
"afterStoredAtMs": 1774700000000,
|
||||
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
|
||||
"afterStoredAtMs": 0,
|
||||
"afterMessageKey": "",
|
||||
"limit": 500,
|
||||
"maxBytes": 3000000
|
||||
"maxBytes": 3000000,
|
||||
"ackSyncIds": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
|
||||
|
||||
### Успешный ответ
|
||||
Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "DmSyncBatch",
|
||||
"requestId": "dm-sync-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"nextStoredAtMs": 1774700001123,
|
||||
"nextMessageKey": "alice|bob|1774700000123|123456789|2",
|
||||
"hasMore": false,
|
||||
"items": [
|
||||
{
|
||||
"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": {
|
||||
"ownerLogin": "alice",
|
||||
"limit": 500,
|
||||
"rawBytes": 84512,
|
||||
"hasMore": true,
|
||||
"nextStoredAtMs": 1774700100000,
|
||||
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"messageKey": "alice|bob|1774700000123|123456789|2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
|
||||
Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя.
|
||||
|
||||
Ошибки:
|
||||
Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `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`
|
||||
|
||||
@@ -355,6 +405,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`.
|
||||
|
||||
@@ -77,15 +77,17 @@
|
||||
Внутренний служебный запрос.
|
||||
|
||||
- помечает все настройки пользователя или все настройки сразу как `synced=false`;
|
||||
- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной;
|
||||
- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий;
|
||||
- нужен после добавления нового sync-сервера или при потере локальной БД.
|
||||
|
||||
## 5. Синхронизация
|
||||
|
||||
Синхронизация настроек работает отдельно от DM.
|
||||
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`.
|
||||
|
||||
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
|
||||
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
|
||||
- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору;
|
||||
- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM;
|
||||
- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля.
|
||||
|
||||
## 6. Текущий UI-кейс
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# API для разработчиков: уведомления
|
||||
|
||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
||||
|
||||
Текущая операция:
|
||||
|
||||
- `GetNotifications`
|
||||
|
||||
## 1. `GetNotifications`
|
||||
|
||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
||||
|
||||
Возвращаются две отдельные ленты:
|
||||
|
||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
@@ -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,7 +183,9 @@ Full resync запускается только тогда, когда:
|
||||
|
||||
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
|
||||
|
||||
## 5. Целевой протокол следующего этапа
|
||||
## 5. Возможное развитие server-to-server транспорта
|
||||
|
||||
Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth.
|
||||
|
||||
### 5.1 Межсерверное соединение
|
||||
|
||||
@@ -192,14 +196,15 @@ Full resync запускается только тогда, когда:
|
||||
|
||||
### 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 +217,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. Статус реализации
|
||||
|
||||
@@ -240,15 +247,18 @@ Full resync запускается только тогда, когда:
|
||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
|
||||
| Push новых DM партнёрам | Нужна реализация |
|
||||
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
||||
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
||||
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||
| 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 соединения.
|
||||
Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов.
|
||||
|
||||
Следующие отдельные шаги после текущего этапа:
|
||||
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||
|
||||
@@ -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,136 @@
|
||||
# Доставка и синхронизация личных сообщений
|
||||
|
||||
## 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, локально сразу считается синхронизированным, чтобы не образовалась петля.
|
||||
|
||||
Синхронизация настроек и 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,14 @@
|
||||
- `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. Серверный слой диалогов
|
||||
|
||||
@@ -446,8 +462,12 @@ Request:
|
||||
Правила:
|
||||
|
||||
- клиенту достаточно отправить пару на один любой доступный сервер;
|
||||
- успешный `status=200` означает локальное сохранение;
|
||||
- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
|
||||
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
|
||||
|
||||
Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`.
|
||||
|
||||
### 10.2. `ReceiveIncomingMessage`
|
||||
|
||||
Назначение:
|
||||
@@ -469,7 +489,7 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
|
||||
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
|
||||
|
||||
### 10.3. `DeleteMessage`
|
||||
|
||||
@@ -558,6 +578,8 @@ UI-следствие для клиента:
|
||||
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
|
||||
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
|
||||
|
||||
Для исходящих элементов сервер также возвращает единственное поле `deliveryState`.
|
||||
|
||||
## 11. Межсерверная доставка
|
||||
|
||||
### 11.1. Клиентская сторона
|
||||
@@ -568,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`, сервер:
|
||||
|
||||
@@ -618,17 +616,17 @@ UI-следствие для клиента:
|
||||
- не отправляет realtime/push-уведомления клиентам;
|
||||
- не запускает повторный fan-out, чтобы не создавать циклы.
|
||||
|
||||
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
|
||||
Синхронизация настроек и DM выполняется одним периодическим процессом и через один последовательный WS-сеанс с 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. Хранение в БД
|
||||
|
||||
@@ -653,11 +651,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
|
||||
|
||||
@@ -670,7 +670,7 @@ UI-следствие для клиента:
|
||||
- межсерверная маршрутизация DM должна идти через `access_servers`;
|
||||
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
|
||||
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
|
||||
- логика должна быть безопасна для нескольких серверов у каждой стороны.
|
||||
- логика должна быть безопасна для одного или двух серверов у каждой стороны.
|
||||
|
||||
## 14. Что в v1 пока не входит
|
||||
|
||||
@@ -678,4 +678,4 @@ UI-следствие для клиента:
|
||||
- хранение отдельного `keyId` шифрования в DM;
|
||||
- ротация `clientKey`;
|
||||
- финальная конкретная UI-реализация массовой перешифровки;
|
||||
- межсерверная авторизация `DmSyncBatch`.
|
||||
- межсерверная авторизация DM-синхронизации и доставки.
|
||||
|
||||
@@ -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. Общие правила
|
||||
|
||||
@@ -337,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`.
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+41
-6
@@ -11,6 +11,7 @@ import {
|
||||
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';
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
deleteConversationMessagesBefore,
|
||||
deleteSignedMessageByBaseKey,
|
||||
markIncomingReadByBaseKey,
|
||||
markOutgoingDeliveryState,
|
||||
markOutgoingReadByBaseKey,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
@@ -85,17 +87,17 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=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 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 = 'Формат сообщения не поддерживается';
|
||||
@@ -195,6 +197,15 @@ 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',
|
||||
@@ -1137,6 +1148,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();
|
||||
@@ -1173,7 +1192,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));
|
||||
@@ -1367,6 +1391,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();
|
||||
|
||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
if (sizeClass) classes.add(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||
if (glow) classes.add('avatar-glow');
|
||||
wrap.className = Array.from(classes).join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.className = 'avatar-photo';
|
||||
img.alt = String(alt || 'Аватар');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
wrap.append(img);
|
||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
} = {}) {
|
||||
return renderAvatar({
|
||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
alt: 'Аватар',
|
||||
glow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header';
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'icon-btn';
|
||||
btn.textContent = leftAction.label;
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, h1, right);
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function createOverflowDots({ className = '' } = {}) {
|
||||
const dots = document.createElement('span');
|
||||
const extra = String(className || '').trim();
|
||||
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
||||
dots.setAttribute('aria-hidden', 'true');
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
dots.append(document.createElement('i'));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function attachScrollToBottomButton({
|
||||
scrollContainer,
|
||||
mountTarget = document.querySelector('.app-shell'),
|
||||
thresholdPx = 160,
|
||||
title = 'Вниз',
|
||||
} = {}) {
|
||||
const target = resolveElement(mountTarget);
|
||||
if (!(target instanceof Element)) {
|
||||
return {
|
||||
button: null,
|
||||
refresh() {},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'scroll-to-bottom-btn';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||
button.tabIndex = -1;
|
||||
button.append(buildArrowIcon());
|
||||
target.append(button);
|
||||
|
||||
let disposed = false;
|
||||
let boundContainer = null;
|
||||
let resizeObserver = null;
|
||||
let mutationObserver = null;
|
||||
let refreshFrame = 0;
|
||||
|
||||
const hide = () => {
|
||||
button.classList.remove('is-visible');
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
button.tabIndex = -1;
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (disposed || refreshFrame) return;
|
||||
refreshFrame = window.requestAnimationFrame(() => {
|
||||
refreshFrame = 0;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const unbindContainer = () => {
|
||||
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
boundContainer = null;
|
||||
};
|
||||
|
||||
const bindContainer = () => {
|
||||
const nextContainer = resolveElement(scrollContainer);
|
||||
if (!(nextContainer instanceof Element)) {
|
||||
if (boundContainer) unbindContainer();
|
||||
return null;
|
||||
}
|
||||
if (nextContainer === boundContainer) return boundContainer;
|
||||
|
||||
unbindContainer();
|
||||
boundContainer = nextContainer;
|
||||
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(scheduleRefresh);
|
||||
resizeObserver.observe(boundContainer);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
mutationObserver = new MutationObserver(scheduleRefresh);
|
||||
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return boundContainer;
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
if (disposed) return;
|
||||
const container = bindContainer();
|
||||
if (!container) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollHeight = Number(container.scrollHeight || 0);
|
||||
const clientHeight = Number(container.clientHeight || 0);
|
||||
const scrollTop = Number(container.scrollTop || 0);
|
||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
||||
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
||||
|
||||
button.classList.toggle('is-visible', shouldShow);
|
||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||
button.tabIndex = shouldShow ? 0 : -1;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = bindContainer();
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
scheduleRefresh();
|
||||
};
|
||||
|
||||
button.addEventListener('click', scrollToBottom);
|
||||
window.addEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
||||
window.requestAnimationFrame(refresh);
|
||||
|
||||
return {
|
||||
button,
|
||||
refresh: scheduleRefresh,
|
||||
cleanup() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (refreshFrame) {
|
||||
window.cancelAnimationFrame(refreshFrame);
|
||||
refreshFrame = 0;
|
||||
}
|
||||
unbindContainer();
|
||||
window.removeEventListener('resize', scheduleRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
||||
button.removeEventListener('click', scrollToBottom);
|
||||
button.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
openArweaveAttachmentManager,
|
||||
markArweaveAttachmentPlaced,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
const label = String(title || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
if (avatar?.ar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readArweaveAttachmentHistory,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
@@ -73,13 +74,14 @@ export function render({ navigate }) {
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню">⋮</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [],
|
||||
rightActions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
const headerLeft = header.querySelector('.header-left');
|
||||
let threadHeaderButton = null;
|
||||
if (headerLeft) {
|
||||
const channelsListButton = document.createElement('button');
|
||||
channelsListButton.type = 'button';
|
||||
channelsListButton.className = 'icon-btn';
|
||||
channelsListButton.textContent = '↑';
|
||||
channelsListButton.title = 'К списку каналов';
|
||||
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
||||
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
||||
headerLeft.append(channelsListButton);
|
||||
|
||||
threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
headerLeft.append(threadHeaderButton);
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
@@ -578,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
if (txId) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: txId ? { ar: txId } : null,
|
||||
size: 'small',
|
||||
className: 'channel-profile-avatar',
|
||||
title,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -2271,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
centerNode: channelHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
],
|
||||
});
|
||||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
@@ -2294,6 +2293,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
};
|
||||
let activeSelector = null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
function createChannelAvatar(channel = {}) {
|
||||
return renderAvatar({
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'small',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
const row = document.createElement('article');
|
||||
row.className = 'channel-row';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||||
<div class="channel-row-main">
|
||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.prepend(createChannelAvatar(channel));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onSubscribeChannel,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 320;
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ divider: true },
|
||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ divider: true },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ divider: true },
|
||||
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
@@ -1206,16 +1213,7 @@ 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);
|
||||
|
||||
@@ -1229,11 +1227,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const count = document.createElement('span');
|
||||
count.className = 'unread channel-row-count';
|
||||
const unreadCount = Number(channel.unreadCount || 0);
|
||||
if (unreadCount > 0) {
|
||||
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
controls.append(count);
|
||||
}
|
||||
controls.append(time);
|
||||
count.hidden = unreadCount <= 0;
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
controls.append(time, count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => {
|
||||
@@ -1250,6 +1248,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
@@ -1337,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);
|
||||
@@ -1382,18 +1363,14 @@ 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';
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
@@ -1411,19 +1388,21 @@ 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 });
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
rerenderList();
|
||||
|
||||
+100
-10
@@ -1,4 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -27,10 +29,55 @@ import {
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function createChatHeaderParts(login) {
|
||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||
|
||||
const avatarSlot = document.createElement('span');
|
||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||
const initialAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.append(initialAvatar);
|
||||
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'chat-header-login';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
loginEl.textContent = cleanLogin;
|
||||
|
||||
void loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
if (!avatarSlot.isConnected) return;
|
||||
const upgradedAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName: String(snapshot?.firstName || '').trim(),
|
||||
lastName: String(snapshot?.lastName || '').trim(),
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.replaceChildren(upgradedAvatar);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { centerNode: loginEl, avatarSlot };
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
@@ -481,10 +528,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 +762,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 +857,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 +980,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 +993,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
{
|
||||
@@ -933,7 +1006,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onClick: () => handleStartCall('audio'),
|
||||
},
|
||||
{
|
||||
label: '⋮',
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
@@ -990,8 +1063,9 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
@@ -1201,7 +1275,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 +1303,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 +1379,7 @@ export function render({ navigate, route, chrome }) {
|
||||
markOutgoingSent(tempId, {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
deliveryState: result?.deliveryState || 'accepted',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1306,6 +1387,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 +1615,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 +1651,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');
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
@@ -16,6 +17,7 @@ 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],
|
||||
@@ -44,13 +46,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) => {
|
||||
@@ -65,6 +68,7 @@ function createDmAvatar(login) {
|
||||
: null,
|
||||
size: 'small',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
upgraded.classList.add('avatar');
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -160,13 +164,10 @@ function compareChatRows(a, b) {
|
||||
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 = `
|
||||
@@ -178,9 +179,7 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
<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">
|
||||
@@ -192,11 +191,10 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const headName = head.querySelector('.dm-head-name');
|
||||
if (headName) headName.textContent = login;
|
||||
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
|
||||
|
||||
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
||||
let persistedCenterLogin = '';
|
||||
let persistedCenterHistory = [];
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({ navigate, route, chrome } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
|
||||
else window.history.replaceState({}, '', nextPath);
|
||||
}
|
||||
|
||||
function setBackButtonState(backBtn) {
|
||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||
backBtn.disabled = centerHistory.length === 0;
|
||||
}
|
||||
|
||||
|
||||
function openSearchModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
if (requestId !== loadSeq) return;
|
||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (!centerHistory.length) return;
|
||||
const prev = centerHistory.pop();
|
||||
if (!prev) {
|
||||
setBackButtonState(backBtnEl);
|
||||
return;
|
||||
}
|
||||
void load(prev, { pushHistory: false });
|
||||
},
|
||||
},
|
||||
rightActions: [
|
||||
{ label: 'Найти', onClick: openSearchModal },
|
||||
],
|
||||
});
|
||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||
setBackButtonState(backBtnEl);
|
||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||
header.classList.add('network-topbar');
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
|
||||
window.setTimeout(() => openSearchModal(), 0);
|
||||
}
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
|
||||
filterBar.append(chip);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header, filterBar);
|
||||
chrome?.setTopbar(header);
|
||||
stage.append(board, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,394 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
const CONNECTION_CLOSE_FRIEND = 10;
|
||||
const profileSnapshotCache = new Map();
|
||||
const profileSnapshotPending = new Map();
|
||||
|
||||
function connectionTypeLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND:
|
||||
return 'близкие друзья';
|
||||
default:
|
||||
return 'новую связь';
|
||||
}
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||
|
||||
function renderList(container) {
|
||||
const active = state.notificationsTab;
|
||||
container.innerHTML = '';
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'meta-muted';
|
||||
description.textContent = active === 'events'
|
||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
||||
|
||||
card.append(title, description, note);
|
||||
container.append(card);
|
||||
function normalizeItem(item) {
|
||||
return {
|
||||
kind: String(item?.kind || ''),
|
||||
createdAtMs: Number(item?.createdAtMs || 0),
|
||||
sourceLogin: String(item?.sourceLogin || ''),
|
||||
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
||||
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
||||
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
||||
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
||||
sourceText: String(item?.sourceText || ''),
|
||||
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
||||
targetLogin: String(item?.targetLogin || ''),
|
||||
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
||||
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
||||
targetBlockHash: String(item?.targetBlockHash || ''),
|
||||
profile: null,
|
||||
engagement: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ chrome } = {}) {
|
||||
function formatRelativeTime(value) {
|
||||
const ts = Number(value || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - ts);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
|
||||
if (diffMs < minute) return 'сейчас';
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
||||
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
||||
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
||||
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
||||
}
|
||||
|
||||
function profileField(snapshot, key) {
|
||||
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
||||
.find((field) => String(field?.key || '') === key);
|
||||
return String(row?.value || '').trim();
|
||||
}
|
||||
|
||||
async function loadCachedProfileSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
|
||||
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
|
||||
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
profileSnapshotCache.set(key, snapshot || null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
profileSnapshotCache.set(key, null);
|
||||
profileSnapshotPending.delete(key);
|
||||
return null;
|
||||
});
|
||||
|
||||
profileSnapshotPending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function normalizeEngagement(source) {
|
||||
if (!source || typeof source !== 'object') return null;
|
||||
const likesCount = Math.max(0, Number(source.likesCount || 0));
|
||||
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
|
||||
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
|
||||
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
|
||||
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
|
||||
|
||||
const result = {
|
||||
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
|
||||
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
|
||||
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
|
||||
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
|
||||
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
|
||||
};
|
||||
|
||||
return Object.values(result).some((count) => count > 0) ? result : null;
|
||||
}
|
||||
|
||||
async function loadSourceEngagement(item) {
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
const blockHash = String(item?.sourceBlockHash || '').trim();
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
|
||||
try {
|
||||
const payload = await authService.getMessageThread(
|
||||
{ blockchainName, blockNumber, blockHash },
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
String(state.session.login || '').trim(),
|
||||
);
|
||||
return normalizeEngagement(payload?.focus);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichItem(item, activeTab) {
|
||||
const [profile, engagement] = await Promise.all([
|
||||
loadCachedProfileSnapshot(item.sourceLogin),
|
||||
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
|
||||
]);
|
||||
return { ...item, profile, engagement };
|
||||
}
|
||||
|
||||
function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events'
|
||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderIdentity(item) {
|
||||
const profile = item.profile;
|
||||
const firstName = profileField(profile, 'first_name');
|
||||
const lastName = profileField(profile, 'last_name');
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
||||
const avatar = profile?.avatar?.txId
|
||||
? {
|
||||
ar: String(profile.avatar.txId || '').trim(),
|
||||
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notification-identity';
|
||||
header.append(renderUserAvatar({
|
||||
login: item.sourceLogin || 'unknown',
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
size: 'small',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'notification-identity-text';
|
||||
|
||||
const primary = document.createElement('div');
|
||||
primary.className = 'notification-identity-primary';
|
||||
const name = document.createElement('strong');
|
||||
name.className = 'notification-person-name';
|
||||
name.textContent = fullName;
|
||||
primary.append(name);
|
||||
|
||||
const login = String(item.sourceLogin || '').trim();
|
||||
if (login) {
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'notification-login';
|
||||
loginEl.textContent = `@${login}`;
|
||||
primary.append(loginEl);
|
||||
}
|
||||
|
||||
const relative = formatRelativeTime(item.createdAtMs);
|
||||
if (relative) {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'notification-time-separator';
|
||||
separator.textContent = '·';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-time';
|
||||
time.textContent = relative;
|
||||
primary.append(separator, time);
|
||||
}
|
||||
|
||||
text.append(primary);
|
||||
header.append(text);
|
||||
return header;
|
||||
}
|
||||
|
||||
function renderEngagement(engagement) {
|
||||
if (!engagement) return null;
|
||||
|
||||
const stats = [
|
||||
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||
|
||||
if (!stats.length) return null;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'notification-engagement';
|
||||
stats.forEach(({ key, icon, label }) => {
|
||||
const stat = document.createElement('span');
|
||||
stat.className = 'notification-engagement-item';
|
||||
stat.title = label;
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'notification-engagement-icon';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconEl.textContent = icon;
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'notification-engagement-count';
|
||||
countEl.textContent = String(engagement[key]);
|
||||
stat.append(iconEl, countEl);
|
||||
row.append(stat);
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function notificationRoute(item, activeTab) {
|
||||
if (activeTab === 'events') {
|
||||
const login = String(item?.sourceLogin || '').trim();
|
||||
return login ? makeProfileRoute(login) : '';
|
||||
}
|
||||
|
||||
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||
const blockNumber = Number(item?.sourceBlockNumber);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
|
||||
|
||||
return makeShineMessageRoute({
|
||||
messageBlockchainName: blockchainName,
|
||||
messageBlockNumber: blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function bindNotificationNavigation(row, routePath, navigate) {
|
||||
if (!routePath || typeof navigate !== 'function') return;
|
||||
|
||||
row.classList.add('notification-card--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'link');
|
||||
|
||||
const open = () => navigate(routePath);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
open();
|
||||
});
|
||||
}
|
||||
|
||||
function renderItem(item, activeTab, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'card stack notification-card';
|
||||
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||
row.append(renderIdentity(item));
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'events') {
|
||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
row.append(action);
|
||||
|
||||
if (activeTab === 'replies') {
|
||||
const body = document.createElement('p');
|
||||
body.className = 'notification-content';
|
||||
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||
row.append(body);
|
||||
|
||||
const engagement = renderEngagement(item.engagement);
|
||||
if (engagement) row.append(engagement);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.innerHTML = `
|
||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
||||
data-tab="replies"
|
||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||
>Ответы</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
||||
data-tab="events"
|
||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||
>События</button>
|
||||
`;
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
renderList(list);
|
||||
|
||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
let requestSeq = 0;
|
||||
|
||||
async function load() {
|
||||
const seq = ++requestSeq;
|
||||
const activeTab = state.notificationsTab;
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
|
||||
try {
|
||||
const payload = await authService.getNotifications(50);
|
||||
if (seq !== requestSeq) return;
|
||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
||||
.map(normalizeItem);
|
||||
if (!baseItems.length) {
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
||||
if (seq !== requestSeq) return;
|
||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось загрузить уведомления';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
||||
card.append(title, text);
|
||||
list.replaceChildren(card);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveNotificationTab(nextTab) {
|
||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||||
state.notificationsTab = normalizedTab;
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||||
const selected = node.dataset.tab === normalizedTab;
|
||||
node.classList.toggle('is-active', selected);
|
||||
node.dataset.selected = selected ? 'true' : 'false';
|
||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||||
setActiveNotificationTab(state.notificationsTab);
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.notificationsTab = btn.dataset.tab;
|
||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderList(list);
|
||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||
if (state.notificationsTab === nextTab) {
|
||||
setActiveNotificationTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNotificationTab(nextTab);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-actions profile-top-actions">
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const topActions = topbar.querySelector('.profile-top-actions');
|
||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
if (!profileMenuButton || profileMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const goTo = (route) => {
|
||||
closeProfileMenu();
|
||||
navigate(route);
|
||||
};
|
||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (profileMenuPortal) closeProfileMenu();
|
||||
else openProfileMenu();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenuPortal) return;
|
||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||
closeProfileMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||
closeProfileMenu();
|
||||
profileMenuButton?.focus();
|
||||
});
|
||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
|
||||
@@ -2882,6 +2882,14 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getNotifications(limit = 50) {
|
||||
const payload = {};
|
||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||
const response = await this.ws.request('GetNotifications', payload);
|
||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getUserConnectionsGraph(login) {
|
||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||
|
||||
+37
-1
@@ -414,6 +414,7 @@ function persistMessageRecord(chatId, row) {
|
||||
readAtMs: Number(row.readAtMs || 0),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
deliveryState: String(row.deliveryState || ''),
|
||||
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -450,6 +451,7 @@ export async function hydrateMessagesFromStore() {
|
||||
readAtMs: Number(row.readAtMs || 0),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
deliveryState: String(row.deliveryState || ''),
|
||||
createdAtMs: Number(row.ts || 0),
|
||||
});
|
||||
});
|
||||
@@ -531,7 +533,11 @@ export function addOutgoingPendingMessage(chatId, text) {
|
||||
return tempId;
|
||||
}
|
||||
|
||||
export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) {
|
||||
export function markOutgoingSent(tempId, {
|
||||
messageKey = '',
|
||||
baseKey = '',
|
||||
deliveryState = 'accepted',
|
||||
} = {}) {
|
||||
if (!tempId) return;
|
||||
const keys = Object.keys(state.chats || {});
|
||||
keys.forEach((chatId) => {
|
||||
@@ -541,6 +547,7 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
|
||||
row.firstTick = true;
|
||||
row.messageKey = messageKey || row.messageKey || '';
|
||||
row.baseKey = baseKey || row.baseKey || '';
|
||||
row.deliveryState = String(deliveryState || row.deliveryState || 'accepted');
|
||||
if (messageKey) {
|
||||
state.knownMessageKeys[messageKey] = true;
|
||||
persistMessageRecord(chatId, row);
|
||||
@@ -549,6 +556,33 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
|
||||
});
|
||||
}
|
||||
|
||||
export function markOutgoingDeliveryState({
|
||||
outgoingKey = '',
|
||||
baseKey = '',
|
||||
deliveryState = '',
|
||||
} = {}) {
|
||||
const normalizedOutgoingKey = String(outgoingKey || '').trim();
|
||||
const normalizedBaseKey = String(baseKey || '').trim();
|
||||
let changed = false;
|
||||
Object.keys(state.chats || {}).forEach((chatId) => {
|
||||
getChatMessages(chatId).forEach((row) => {
|
||||
if (row?.from !== 'out') return;
|
||||
const matches = (
|
||||
(normalizedOutgoingKey && String(row.messageKey || '') === normalizedOutgoingKey)
|
||||
|| (normalizedBaseKey && String(row.baseKey || '') === normalizedBaseKey)
|
||||
);
|
||||
if (!matches) return;
|
||||
row.deliveryState = String(deliveryState || row.deliveryState || 'accepted');
|
||||
if (row.deliveryState === 'delivered') {
|
||||
row.firstTick = true;
|
||||
}
|
||||
persistMessageRecord(chatId, row);
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) {
|
||||
if (!baseKey) return;
|
||||
const normalizedReadAtMs = Number(readAtMs || 0);
|
||||
@@ -629,6 +663,7 @@ export function addSignedMessageToChat({
|
||||
rawBlobB64 = '',
|
||||
refBaseKey = '',
|
||||
revisionTimeMs = 0,
|
||||
deliveryState = '',
|
||||
deleted = false,
|
||||
} = {}) {
|
||||
const normalizedChatId = normalizeDmChatId(chatId);
|
||||
@@ -663,6 +698,7 @@ export function addSignedMessageToChat({
|
||||
row.messageType = Number(messageType || 0);
|
||||
row.rawBlobB64 = String(rawBlobB64 || '');
|
||||
row.revisionTimeMs = nextRevision;
|
||||
row.deliveryState = String(deliveryState || existing?.deliveryState || (row.from === 'out' ? 'accepted' : ''));
|
||||
row.unread = row.from === 'in' ? Boolean(unread) : false;
|
||||
row.refBaseKey = String(refBaseKey || '');
|
||||
row.firstTick = row.from === 'out';
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Единый визуальный язык кнопок основного приложения:
|
||||
* белое содержимое, без рамок и самостоятельной подложки.
|
||||
*
|
||||
* Исключения:
|
||||
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
||||
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root a.primary-btn,
|
||||
:root a.secondary-btn,
|
||||
:root a.destructive-btn,
|
||||
:root a.ghost-btn,
|
||||
:root a.icon-btn,
|
||||
:root a.text-btn {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root a.primary-btn:hover,
|
||||
:root a.secondary-btn:hover,
|
||||
:root a.destructive-btn:hover,
|
||||
:root a.ghost-btn:hover,
|
||||
:root a.icon-btn:hover,
|
||||
:root a.text-btn:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
||||
* Эффект существует только пока кнопка физически нажата.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root a.primary-btn:active,
|
||||
:root a.secondary-btn:active,
|
||||
:root a.destructive-btn:active,
|
||||
:root a.ghost-btn:active,
|
||||
:root a.icon-btn:active,
|
||||
:root a.text-btn:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
||||
:root a.primary-btn[aria-disabled='true'],
|
||||
:root a.secondary-btn[aria-disabled='true'],
|
||||
:root a.destructive-btn[aria-disabled='true'],
|
||||
:root a.ghost-btn[aria-disabled='true'],
|
||||
:root a.icon-btn[aria-disabled='true'],
|
||||
:root a.text-btn[aria-disabled='true'] {
|
||||
color: rgba(255, 255, 255, 0.42) !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
||||
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
||||
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
||||
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
||||
*/
|
||||
:root .toolbar-btn {
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
:root .toolbar-btn:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
background: rgba(0, 0, 0, 0.14) !important;
|
||||
box-shadow:
|
||||
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root a.primary-btn:focus-visible,
|
||||
:root a.secondary-btn:focus-visible,
|
||||
:root a.destructive-btn:focus-visible,
|
||||
:root a.ghost-btn:focus-visible,
|
||||
:root a.icon-btn:focus-visible,
|
||||
:root a.text-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Уведомления: «Ответы / События».
|
||||
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
||||
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
||||
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.18) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||
transform: translateY(1px) scale(0.965) !important;
|
||||
filter: brightness(0.88) !important;
|
||||
}
|
||||
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
||||
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
filter: brightness(0.9) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
||||
* Это правило намеренно расположено после глобального белого button-rule. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: var(--app-topbar-gold) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
||||
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||
}
|
||||
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
||||
color: #FFFFFF !important;
|
||||
outline: none !important;
|
||||
filter:
|
||||
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
||||
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
||||
}
|
||||
|
||||
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
||||
:root .dm-chat-input button.dm-emoji-btn,
|
||||
:root .dm-chat-input button.dm-send-btn,
|
||||
:root .dm-chat-input button.dm-edit-banner__close,
|
||||
:root .dm-chat-input button.dm-emoji-btn:hover,
|
||||
:root .dm-chat-input button.dm-send-btn:hover,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
||||
:root .dm-chat-input button.dm-emoji-btn:focus,
|
||||
:root .dm-chat-input button.dm-send-btn:focus,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
||||
}
|
||||
+1492
-6
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@
|
||||
}
|
||||
.fg-orb-host .fg-pngorb-init {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
||||
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||
}
|
||||
|
||||
.fg-node.is-family .node-dot {
|
||||
@@ -422,7 +422,7 @@
|
||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||
.fg-filter-bar {
|
||||
position: absolute;
|
||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
||||
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
|
||||
Reference in New Issue
Block a user