Compare commits

...
Author SHA256 Message Date
AidarKC db37f35d09 Версии: поднять client и server для merge в main 2026-08-23 11:38:07 +04:00
AidarKC d8f5fe4fd0 Merge branch 'расширение-функционала-каналов' 2026-08-23 11:37:52 +04:00
AidarKC 015fade4c0 Смерджить main в ветку расширения функционала каналов 2026-08-22 20:16:16 +04:00
AidarKC da14b51e80 Исправить стартовый bootstrap DM 2026-08-22 20:13:14 +04:00
AidarKC 7a5ac01d1c UI: render DM inbox from dialogs 2026-08-22 18:47:14 +04:00
AidarKC 5e6d64e965 Split DM dialog migration to v11 2026-08-22 18:38:07 +04:00
AidarKC c425fa41aa UI: keep local shell updates 2026-08-22 18:34:40 +04:00
AidarKC 781157299f Server-side DM dialogs state 2026-08-22 18:31:32 +04:00
tram 0f30317bb4 Поднять бейдж канала и опустить время 2026-08-22 17:00:26 +03:00
tram 1f1bc0a7b9 Убрать меню из строк каналов 2026-08-22 16:51:37 +03:00
tram 60c8a6608a Вернуть channels-list к версии ui-artem 2026-08-22 16:47:12 +03:00
tram 86adaf8c6b Слить main с актуальными UI-изменениями 2026-08-22 16:36:47 +03:00
tram 731688d16e Merge ui-artem into main 2026-08-22 16:35:44 +03:00
tram 63b66c48d8 Доточить позиционирование UI 2026-08-22 16:35:41 +03:00
tram ea19e511c0 Доработать аватары и меню UI 2026-08-22 13:27:08 +03:00
tram f5e401cff8 Пересобрать верхний UI-бар 2026-08-22 12:34:07 +03:00
tram bc8c7f318b Обновить UI-кнопки и прокрутку 2026-08-22 11:28:12 +03:00
AidarKC 585750007d Merge remote-tracking branch 'origin/main' 2026-08-21 15:16:42 +04:00
AidarKC 6e80ac976a Merge branch 'расширение-функционала-каналов' 2026-08-21 15:16:13 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
tram d3e6aa2be2 Добавить поддержку уведомлений 2026-08-21 14:11:47 +03:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
tram 19362b950a Доработать UI уведомлений 2026-08-21 14:06:19 +03:00
AidarKC 0a4c31fb36 Refine unread marker and reset registration flow 2026-08-21 15:04:29 +04:00
AidarKC fef7694b48 Show unread count in channel list 2026-08-21 14:59:03 +04:00
AidarKC 60206e21df Accept user settings cursor writes on fallback 2026-08-21 14:52:45 +04:00
AidarKC b9b77c66ce Restore unread baseline and read tracking 2026-08-21 14:24:46 +04:00
AidarKC 745a0e39d7 Fix unread channel counts and badges 2026-08-21 13:45:49 +04:00
60 changed files with 4790 additions and 421 deletions
@@ -98,14 +98,9 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в близкие друзья (close friend). */ /** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */ /** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
/** Добавить в контакты. */ /** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_CONTACT = 20;
@@ -66,7 +66,7 @@ import java.util.Objects;
* toBlockHash32=hash32(CREATE_CHANNEL) * toBlockHash32=hash32(CREATE_CHANNEL)
* *
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). * - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
* *
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
private static boolean isValidSubType(short st) { private static boolean isValidSubType(short st) {
int v = st & 0xFFFF; int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
@@ -26,6 +26,8 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_7 = 7; public static final int SCHEMA_VERSION_7 = 7;
public static final int SCHEMA_VERSION_8 = 8; public static final int SCHEMA_VERSION_8 = 8;
public static final int SCHEMA_VERSION_9 = 9; public static final int SCHEMA_VERSION_9 = 9;
public static final int SCHEMA_VERSION_10 = 10;
public static final int SCHEMA_VERSION_11 = 11;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -35,6 +37,8 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql"; public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql"; public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql"; public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -53,10 +57,8 @@ public final class DatabaseInitializer {
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2; public static final short REACTION_UNLIKE = 2;
public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_CLOSE_FRIEND = 10;
public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_UNCLOSE_FRIEND = 11;
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_CONTACT = 20;
public static final short CONNECTION_UNCONTACT = 21; public static final short CONNECTION_UNCONTACT = 21;
@@ -130,6 +132,15 @@ public final class DatabaseInitializer {
} }
if (currentVersion < SCHEMA_VERSION_9) { if (currentVersion < SCHEMA_VERSION_9) {
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE); runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
currentVersion = SCHEMA_VERSION_9;
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
currentVersion = SCHEMA_VERSION_10;
}
if (currentVersion < SCHEMA_VERSION_11) {
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
currentVersion = SCHEMA_VERSION_11;
} }
} }
} }
@@ -1,6 +1,7 @@
package shine.db; package shine.db;
import shine.db.connection.DbProvider; import shine.db.connection.DbProvider;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
private static volatile DbController instance; private static volatile DbController instance;
private final PostgresDbController delegate; private final PostgresDbController delegate;
private volatile boolean dmDialogStateBootstrapped;
private DbController() { private DbController() {
this.delegate = PostgresDbController.getInstance(); this.delegate = PostgresDbController.getInstance();
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
if (instance == null) { if (instance == null) {
synchronized (DbController.class) { synchronized (DbController.class) {
if (instance == null) { if (instance == null) {
instance = new DbController(); DbController created = new DbController();
instance = created;
created.bootstrapDmDialogStateIfNeeded();
} }
} }
} }
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
public void close() { public void close() {
delegate.close(); delegate.close();
} }
private void bootstrapDmDialogStateIfNeeded() {
if (dmDialogStateBootstrapped) return;
synchronized (this) {
if (dmDialogStateBootstrapped) return;
try (Connection connection = delegate.getConnection()) {
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
dmDialogStateBootstrapped = true;
} catch (SQLException e) {
instance = null;
throw new RuntimeException("DM dialog state bootstrap failed", e);
}
}
}
} }
@@ -58,23 +58,17 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
/** /**
* Совпадает с ConnectionBody: * Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, * SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74 * KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55, * UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75 * UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
*/ */
/** Добавить в близкие друзья (close friend). */ /** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */ /** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
/** Добавить в контакты. */ /** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_CONTACT = 20;
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException { private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
UPDATE message_stats UPDATE message_stats
SET likes_count = MAX( SET likes_count = GREATEST(
0, 0,
likes_count - ( likes_count - COALESCE((
SELECT COUNT(*) SELECT COUNT(*)::int
FROM reactions_state rs FROM reactions_state rs
WHERE rs.from_bch_name = ? WHERE rs.from_bch_name = ?
AND rs.reaction_type = ? AND rs.reaction_type = ?
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
AND rs.to_block_number = message_stats.to_block_number AND rs.to_block_number = message_stats.to_block_number
AND rs.to_block_hash = message_stats.to_block_hash AND rs.to_block_hash = message_stats.to_block_hash
AND rs.to_bch_name <> ? AND rs.to_bch_name <> ?
) ), 0)
) )
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 SELECT 1
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException { private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
UPDATE message_stats UPDATE message_stats
SET replies_count = MAX( SET replies_count = GREATEST(
0, 0,
replies_count - COALESCE(( replies_count - COALESCE((
SELECT COUNT(*) SELECT COUNT(*)::int
FROM blocks b FROM blocks b
WHERE b.bch_name = ? WHERE b.bch_name = ?
AND b.msg_type = 1 AND b.msg_type = 1
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
return out; return out;
} }
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
String sql = """
SELECT 1
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
AND (
LOWER(cs.to_login) = LOWER(?)
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
)
LIMIT 1
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setInt(2, relType);
ps.setString(3, peerLoginAnyCase);
ps.setString(4, peerLoginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
/** /**
* Incoming: список логинов (канонических), кто поставил relType пользователю login. * Incoming: список логинов (канонических), кто поставил relType пользователю login.
*/ */
@@ -0,0 +1,453 @@
package shine.db.dao;
import shine.db.MsgSubType;
import shine.db.entities.SignedMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Base64;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class DmDialogStateDAO {
private static volatile DmDialogStateDAO instance;
private DmDialogStateDAO() {}
public static DmDialogStateDAO getInstance() {
if (instance == null) {
synchronized (DmDialogStateDAO.class) {
if (instance == null) instance = new DmDialogStateDAO();
}
}
return instance;
}
public void bootstrapIfEmpty(Connection c) throws SQLException {
if (c == null) return;
if (hasAnyRow(c)) return;
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
for (PairItem pair : listConversationPairs(c)) {
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
}
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException sqlEx) throw sqlEx;
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
} finally {
c.setAutoCommit(prevAutoCommit);
}
}
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
refreshConversation(c, cleanOwner, cleanPeer);
refreshConversation(c, cleanPeer, cleanOwner);
}
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
if (cleanOwner.isEmpty()) return List.of();
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
try (PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms,
updated_at_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
""")) {
ps.setString(1, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
DialogSummary row = new DialogSummary(
rs.getString("owner_login"),
rs.getString("peer_login"),
normalizeRelationFlag(rs.getString("relation_flag")),
rs.getString("last_message_blob_b64"),
rs.getLong("last_message_time_ms"),
rs.getInt("unread_count"),
rs.getLong("last_read_receipt_time_ms"),
rs.getLong("updated_at_ms"),
true
);
byPeer.put(normKey(row.peerLogin()), row);
}
}
}
for (String peer : contacts) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
}
for (String peer : closeFriends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
}
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
row.ownerLogin(),
row.peerLogin(),
relationFlag,
row.lastMessageBlobB64(),
row.lastMessageTimeMs(),
row.unreadCount(),
row.lastReadReceiptTimeMs(),
row.updatedAtMs(),
row.hasDialog()
));
}
List<DialogSummary> out = new ArrayList<>(byPeer.values());
out.sort((a, b) -> {
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
if (cmp != 0) return cmp;
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
});
return out;
}
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
if (entry == null) return;
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
}
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
upsert(c, summary);
if (summary.lastReadReceiptTimeMs() > 0) {
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
}
}
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
}
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
String latestSql = """
SELECT raw_block, time_ms
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
LIMIT 1
""";
String unreadSql = """
SELECT COUNT(*)
FROM signed_messages
WHERE LOWER(from_login) = LOWER(?)
AND LOWER(to_login) = LOWER(?)
AND message_type = 1
AND time_ms > ?
AND (read_at_ms IS NULL OR read_at_ms <= 0)
""";
String contentReadSql = """
SELECT MAX(read_at_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND read_at_ms IS NOT NULL
AND read_at_ms > 0
""";
String receiptWatermarkSql = """
SELECT MAX(time_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (3, 4)
""";
String lastMessageBlobB64 = "";
long lastMessageTimeMs = 0L;
int unreadCount = 0;
long lastReadReceiptTimeMs = existingWatermark;
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
lastMessageTimeMs = rs.getLong("time_ms");
byte[] rawBlock = rs.getBytes("raw_block");
if (rawBlock != null && rawBlock.length > 0) {
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
}
}
}
}
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
ps.setString(1, cleanPeer);
ps.setString(2, cleanOwner);
ps.setLong(3, lastReadReceiptTimeMs);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) unreadCount = rs.getInt(1);
}
}
return new DialogSummary(
cleanOwner,
cleanPeer,
relationFlag,
lastMessageBlobB64,
lastMessageTimeMs,
unreadCount,
lastReadReceiptTimeMs,
System.currentTimeMillis(),
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
);
}
private void upsert(Connection c, DialogSummary summary) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_dialog_state (
owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
relation_flag = EXCLUDED.relation_flag,
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
last_message_time_ms = EXCLUDED.last_message_time_ms,
unread_count = EXCLUDED.unread_count,
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, summary.ownerLogin());
ps.setString(2, summary.peerLogin());
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
ps.setString(4, summary.lastMessageBlobB64());
ps.setLong(5, summary.lastMessageTimeMs());
ps.setInt(6, summary.unreadCount());
ps.setLong(7, summary.lastReadReceiptTimeMs());
ps.setLong(8, summary.updatedAtMs());
ps.executeUpdate();
}
}
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
private boolean hasAnyRow(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
return rs.next();
}
}
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
String sql = """
SELECT DISTINCT owner_login, peer_login
FROM (
SELECT
m.target_login AS owner_login,
CASE
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
ELSE m.to_login
END AS peer_login
FROM signed_messages m
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
UNION ALL
SELECT m.from_login AS owner_login, m.to_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
UNION ALL
SELECT m.to_login AS owner_login, m.from_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
) pairs
WHERE owner_login IS NOT NULL
AND peer_login IS NOT NULL
AND BTRIM(owner_login) <> ''
AND BTRIM(peer_login) <> ''
AND LOWER(owner_login) <> LOWER(peer_login)
ORDER BY owner_login, peer_login
""";
List<PairItem> out = new ArrayList<>();
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
String ownerLogin = rs.getString("owner_login");
String peerLogin = rs.getString("peer_login");
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
out.add(new PairItem(ownerLogin, peerLogin));
}
}
return out;
}
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
String owner = normalize(ownerLogin);
String peer = normalize(peerLogin);
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
String key = normKey(peer);
DialogSummary current = map.get(key);
if (current == null) {
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
return;
}
String nextRelation = current.relationFlag();
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "close_friend";
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
nextRelation = "contact";
} else {
nextRelation = normalizeRelationFlag(nextRelation);
}
map.put(key, new DialogSummary(
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
peer,
nextRelation,
current.lastMessageBlobB64(),
current.lastMessageTimeMs(),
current.unreadCount(),
current.lastReadReceiptTimeMs(),
current.updatedAtMs(),
current.hasDialog() || hasDialog
));
}
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
if (watermark <= 0) return;
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_messages
SET read_at_ms = CASE
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
WHEN read_at_ms > ? THEN read_at_ms
ELSE read_at_ms
END
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND time_ms <= ?
""")) {
ps.setLong(1, watermark);
ps.setLong(2, watermark);
ps.setString(3, cleanOwner);
ps.setString(4, cleanPeer);
ps.setString(5, cleanPeer);
ps.setString(6, cleanOwner);
ps.setLong(7, watermark);
ps.executeUpdate();
}
}
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT last_read_receipt_time_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(peer_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, peerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0L;
long value = rs.getLong(1);
return rs.wasNull() ? 0L : value;
}
}
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String normKey(String value) {
return normalize(value).toLowerCase(Locale.ROOT);
}
private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT);
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
return "none";
}
public record DialogSummary(
String ownerLogin,
String peerLogin,
String relationFlag,
String lastMessageBlobB64,
long lastMessageTimeMs,
int unreadCount,
long lastReadReceiptTimeMs,
long updatedAtMs,
boolean hasDialog
) {}
private record PairItem(String ownerLogin, String peerLogin) {}
}
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER; ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
if (status.applied()) { if (status.applied()) {
markMessageReadByReceipt(c, e); markMessageReadByReceipt(c, e);
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
} }
return status; return status;
} }
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
if (insertedFirst == 1 && insertedSecond == 1) { if (insertedFirst == 1 && insertedSecond == 1) {
markMessageReadByReceipt(c, first); markMessageReadByReceipt(c, first);
markMessageReadByReceipt(c, second); markMessageReadByReceipt(c, second);
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
c.commit(); c.commit();
return true; return true;
} }
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
markMessageReadByReceipt(c, outgoing); markMessageReadByReceipt(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey()); resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey()); resetDeliveryRows(c, outgoing.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit(); c.commit();
return ApplyStatus.APPLIED; return ApplyStatus.APPLIED;
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
upsertMessage(c, incoming); upsertMessage(c, incoming);
markMessageReadByReceipt(c, incoming); markMessageReadByReceipt(c, incoming);
resetDeliveryRows(c, incoming.getMessageKey()); resetDeliveryRows(c, incoming.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit(); c.commit();
return ApplyStatus.APPLIED; return ApplyStatus.APPLIED;
} catch (Exception ex) { } catch (Exception ex) {
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
upsertMessage(c, tombstone); upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey()); resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit(); c.commit();
return ApplyStatus.APPLIED; return ApplyStatus.APPLIED;
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
upsertMessage(c, tombstone); upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey()); resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit(); c.commit();
return ApplyStatus.APPLIED; return ApplyStatus.APPLIED;
@@ -0,0 +1,105 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserNotificationEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
public final class UserNotificationsStateDAO {
private static volatile UserNotificationsStateDAO instance;
private final DbController db = DbController.getInstance();
private UserNotificationsStateDAO() {}
public static UserNotificationsStateDAO getInstance() {
if (instance == null) {
synchronized (UserNotificationsStateDAO.class) {
if (instance == null) instance = new UserNotificationsStateDAO();
}
}
return instance;
}
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
String sql = """
INSERT INTO user_notifications_state (
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
created_at_ms = EXCLUDED.created_at_ms,
source_login = EXCLUDED.source_login,
target_login = EXCLUDED.target_login,
target_bch_name = EXCLUDED.target_bch_name,
target_block_number = EXCLUDED.target_block_number,
target_block_hash = EXCLUDED.target_block_hash,
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
source_text = EXCLUDED.source_text
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getOwnerLogin());
ps.setString(i++, e.getNotificationKind());
ps.setLong(i++, e.getCreatedAtMs());
ps.setString(i++, e.getSourceLogin());
ps.setString(i++, e.getSourceBchName());
ps.setInt(i++, e.getSourceBlockNumber());
ps.setBytes(i++, e.getSourceBlockHash());
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
ps.setInt(i++, e.getSourceMsgSubType());
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
return ps.executeUpdate();
}
}
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
String sql = """
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
FROM user_notifications_state
WHERE owner_login = ? AND notification_kind = ?
ORDER BY created_at_ms DESC, source_block_number DESC
LIMIT ?
""";
List<UserNotificationEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, kind);
ps.setInt(3, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
UserNotificationEntry e = new UserNotificationEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setNotificationKind(rs.getString("notification_kind"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setSourceLogin(rs.getString("source_login"));
e.setSourceBchName(rs.getString("source_bch_name"));
e.setSourceBlockNumber(rs.getInt("source_block_number"));
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
e.setTargetLogin(rs.getString("target_login"));
e.setTargetBchName(rs.getString("target_bch_name"));
Object targetBlockNumber = rs.getObject("target_block_number");
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
e.setSourceText(rs.getString("source_text"));
return e;
}
}
@@ -0,0 +1,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,32 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_notifications_state (
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
source_block_hash BYTEA NOT NULL,
target_login TEXT,
target_bch_name TEXT,
target_block_number INTEGER,
target_block_hash BYTEA,
source_msg_sub_type INTEGER NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
ON user_notifications_state(owner_login, created_at_ms DESC);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,30 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
ALTER TABLE IF EXISTS dm_dialog_state
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
); );
INSERT INTO db_schema_version (id, schema_version, updated_at_ms) INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 9, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) VALUES (1, 11, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version, schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms; updated_at_ms = EXCLUDED.updated_at_ms;
@@ -751,6 +751,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
ON signed_message_session_delivery(session_id, delivered); ON signed_message_session_delivery(session_id, delivered);
CREATE TABLE IF NOT EXISTS user_notifications_state (
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
source_block_hash BYTEA NOT NULL,
target_login TEXT,
target_bch_name TEXT,
target_block_number INTEGER,
target_block_hash BYTEA,
source_msg_sub_type INTEGER NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
ON user_notifications_state(owner_login, created_at_ms DESC);
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL, owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL, remote_server_login TEXT NOT NULL,
@@ -88,9 +88,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
@@ -207,6 +209,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
// --- direct messages / push --- // --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
@@ -296,6 +299,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
// --- direct messages / push --- // --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType; import blockchain.MsgSubType;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget; import blockchain.body.BodyHasTarget;
import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.StatusActionBody; import blockchain.body.StatusActionBody;
import blockchain.body.TextReplyBody;
import blockchain.body.TextLineBody; import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody; import blockchain.body.UserParamBody;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO; import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.dao.UserParamsDAO; import shine.db.dao.UserParamsDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserNotificationEntry;
import shine.db.entities.UserParamEntry; import shine.db.entities.UserParamEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance(); private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService(); private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO); private final BlockchainWriter dbWriter = new BlockchainWriter(
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
public Net_AddBlock_Handler() { public Net_AddBlock_Handler() {
ChannelNamesStateBootstrapper.bootstrapOrFailFast(); ChannelNamesStateBootstrapper.bootstrapOrFailFast();
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
); );
} }
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry); UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
dbWriter.appendBlockAndState(
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
if (chat200CreateSeed != null) { if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed); upsertChat200StateFromCreate(chat200CreateSeed);
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return b; return b;
} }
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
if (block == null || storedEntry == null) return null;
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
long createdAtMs = block.timestamp * 1000L;
String ownerLogin = storedEntry.getToLogin();
String sourceLogin = storedEntry.getLogin();
// A user must never receive a notification for replying to their own message/thread.
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
if (msgType == 1
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
&& block.body instanceof TextReplyBody replyBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("reply");
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
return entry;
}
// Connection notifications are intentionally modeled as a generic kind.
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
// can reuse the same notification kind and expose their code via sourceMsgSubType.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
&& block.body instanceof ConnectionBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("connection");
entry.setSourceText("");
return entry;
}
return null;
}
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
String ownerLogin,
long createdAtMs,
int msgSubType) {
UserNotificationEntry entry = new UserNotificationEntry();
entry.setOwnerLogin(ownerLogin);
entry.setCreatedAtMs(createdAtMs);
entry.setSourceLogin(storedEntry.getLogin());
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
entry.setSourceBlockHash(storedEntry.getBlockHash());
entry.setTargetLogin(storedEntry.getToLogin());
entry.setTargetBchName(storedEntry.getToBchName());
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
entry.setTargetBlockHash(storedEntry.getToBlockHash());
entry.setSourceMsgSubType(msgSubType);
return entry;
}
private String blockchainNameFromEntry(BlockEntry entry) {
return entry == null ? null : entry.getBchName();
}
private static String toHex(byte[] bytes) { private static String toHex(byte[] bytes) {
if (bytes == null) return "null"; if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray(); char[] HEX = "0123456789abcdef".toCharArray();
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO; import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserParamsDAO; import shine.db.dao.UserParamsDAO;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry; import shine.db.entities.UserParamEntry;
import shine.db.entities.UserNotificationEntry;
import utils.files.FileStoreUtil; import utils.files.FileStoreUtil;
import java.io.IOException; import java.io.IOException;
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
private final BlockchainStateDAO stateDAO; private final BlockchainStateDAO stateDAO;
private final ChannelNameStateDAO channelNameStateDAO; private final ChannelNameStateDAO channelNameStateDAO;
private final UserParamsDAO userParamsDAO; private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO;
private final FileStoreUtil fs = FileStoreUtil.getInstance(); private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO, public BlockchainWriter(BlocksDAO blocksDAO,
BlockchainStateDAO stateDAO, BlockchainStateDAO stateDAO,
UserParamsDAO userParamsDAO, UserParamsDAO userParamsDAO,
ChannelNameStateDAO channelNameStateDAO) { ChannelNameStateDAO channelNameStateDAO,
UserNotificationsStateDAO userNotificationsStateDAO) {
this.blocksDAO = blocksDAO; this.blocksDAO = blocksDAO;
this.stateDAO = stateDAO; this.stateDAO = stateDAO;
this.userParamsDAO = userParamsDAO; this.userParamsDAO = userParamsDAO;
this.channelNameStateDAO = channelNameStateDAO; this.channelNameStateDAO = channelNameStateDAO;
this.userNotificationsStateDAO = userNotificationsStateDAO;
} }
public void appendBlockAndState(String blockchainName, public void appendBlockAndState(String blockchainName,
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
BlockEntry be, BlockEntry be,
UserParamEntry userParamEntry, UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry, ChannelNameStateEntry channelNameStateEntry,
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException { ChannelNameStateEntry channelMetaUpdateEntry,
UserNotificationEntry notificationEntry) throws SQLException {
long nowMs = System.currentTimeMillis(); long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes(); byte[] blockBytes = block.toBytes();
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
} }
// Notification projection is part of the same SQL transaction as the block.
// If notification indexing fails, the block/state write is rolled back as well.
if (notificationEntry != null) {
userNotificationsStateDAO.upsert(c, notificationEntry);
}
c.commit(); c.commit();
committed = true; committed = true;
} catch (Exception e) { } catch (Exception e) {
@@ -8,10 +8,10 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType; import shine.db.dao.DmDialogStateDAO;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection; import java.sql.Connection;
import java.util.ArrayList;
import java.util.List; import java.util.List;
public class Net_ListContacts_Handler implements JsonMessageHandler { public class Net_ListContacts_Handler implements JsonMessageHandler {
@@ -23,14 +23,30 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
} }
try (Connection c = shine.db.DbController.getInstance().getConnection()) { try (Connection c = shine.db.DbController.getInstance().getConnection()) {
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT); List<DmDialogStateDAO.DialogSummary> dialogs = DmDialogStateDAO.getInstance().listInboxDialogs(c, ctx.getLogin());
Net_ListContacts_Response resp = new Net_ListContacts_Response(); Net_ListContacts_Response resp = new Net_ListContacts_Response();
resp.setOp(req.getOp()); resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin()); resp.setLogin(ctx.getLogin());
resp.setContacts(contacts); resp.setDialogs(toDialogItems(dialogs));
return resp; return resp;
} }
} }
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
if (dialogs == null) return items;
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
item.setPeerLogin(dialog.peerLogin());
item.setRelationFlag(dialog.relationFlag());
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
item.setUnreadCount(dialog.unreadCount());
item.setHasDialog(dialog.hasDialog());
items.add(item);
}
return items;
}
} }
@@ -7,10 +7,32 @@ import java.util.List;
public class Net_ListContacts_Response extends Net_Response { public class Net_ListContacts_Response extends Net_Response {
private String login; private String login;
private List<String> contacts = new ArrayList<>(); private List<DialogItem> dialogs = new ArrayList<>();
public String getLogin() { return login; } public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; } public void setLogin(String login) { this.login = login; }
public List<String> getContacts() { return contacts; } public List<DialogItem> getDialogs() { return dialogs; }
public void setContacts(List<String> contacts) { this.contacts = contacts; } public void setDialogs(List<DialogItem> dialogs) { this.dialogs = dialogs; }
public static class DialogItem {
private String peerLogin;
private String relationFlag;
private String lastMessageBlobB64;
private long lastMessageTimeMs;
private int unreadCount;
private boolean hasDialog;
public String getPeerLogin() { return peerLogin; }
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
public String getRelationFlag() { return relationFlag; }
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
public void setLastMessageTimeMs(long lastMessageTimeMs) { this.lastMessageTimeMs = lastMessageTimeMs; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public boolean isHasDialog() { return hasDialog; }
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
}
} }
@@ -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);
}
}
@@ -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; }
}
@@ -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; }
}
}
@@ -74,10 +74,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
+ escapePart(valueText) + '|' + escapePart(valueText) + '|'
+ valueNum; + valueNum;
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
}
DbController db = DbController.getInstance(); DbController db = DbController.getInstance();
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance(); UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
@@ -95,6 +91,14 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
} }
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
if (!signatureOk) {
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
login, settingType, settingKey);
}
UserSettingEntry entry = new UserSettingEntry( UserSettingEntry entry = new UserSettingEntry(
login, login,
settingType, settingType,
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) FRIEND взаимно (на HEADER) // 3) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND, sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash, bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t); "U1 -> U2: FRIEND", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND, sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch1, u1HeaderBlock, u1HeaderHash, bch1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t); "U2 -> U1: FRIEND", t);
@@ -0,0 +1,21 @@
# TODO: уведомления и full-resync после фиксации блоков в Arweave
## Контекст
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
## Что проверить после внедрения Arweave
Когда схема Arweave и правила восстановления цепочки стабилизируются:
- определить окончательный source of truth для accepted/finalized blocks;
- проверить поведение `user_notifications_state` при rollback/full-resync;
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
- добавить интеграционный тест на divergence + full-resync + notification projection.
## Важно
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.7.0 client.version=1.8.0
server.version=1.6.1 server.version=1.7.0
+173
View File
@@ -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}')"
+3
View File
@@ -23,6 +23,9 @@
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. 7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
--- ---
+1
View File
@@ -58,6 +58,7 @@
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя | | `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя | | `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга | | `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
+34 -1
View File
@@ -66,11 +66,44 @@
"ok": true, "ok": true,
"payload": { "payload": {
"login": "Alice", "login": "Alice",
"contacts": ["Bob", "Kate"] "dialogs": [
{
"peerLogin": "Bob",
"relationFlag": "close_friend",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000123,
"unreadCount": 2,
"hasDialog": true
},
{
"peerLogin": "Kate",
"relationFlag": "contact",
"lastMessageBlobB64": "",
"lastMessageTimeMs": 0,
"unreadCount": 0,
"hasDialog": false
},
{
"peerLogin": "Mira",
"relationFlag": "none",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000555,
"unreadCount": 1,
"hasDialog": true
}
]
} }
} }
``` ```
### Примечание
- `dialogs` это серверный inbox-проекционный список диалогов;
- `relationFlag` возвращается как `close_friend`, `contact` или `none`;
- если один и тот же человек есть и в `contact`, и в `close_friend`, в `dialogs` он приходит как `close_friend`.
- `lastMessageBlobB64` содержит полный signed DM block последнего контентного сообщения в base64;
- для чатов без сообщений поле `lastMessageBlobB64` пустое.
--- ---
## 3. `GetUserConnectionsGraph` ## 3. `GetUserConnectionsGraph`
@@ -11,6 +11,8 @@
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; - для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. - `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
## 1. `UpsertPushToken` ## 1. `UpsertPushToken`
@@ -147,6 +149,12 @@
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. `sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
### Примечание
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
## 5. `DeleteMessage` ## 5. `DeleteMessage`
Принимает один signed DM-блок `type=5` или `type=6`. Принимает один signed DM-блок `type=5` или `type=6`.
@@ -359,7 +367,7 @@
- все DM-типы `1..8` используют `SHiNE_DM` - все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE - `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
- сервер не расшифровывает DM и не использует ciphertext как preview текста - сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs` - сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон - если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
- HTTP endpoints для DM-файлов сейчас отсутствуют - HTTP endpoints для DM-файлов сейчас отсутствуют
+2 -1
View File
@@ -13,7 +13,8 @@
- `value_num = number of messages already seen in channel`; - `value_num = number of messages already seen in channel`;
- `value_text = ''`. - `value_text = ''`.
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`. Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
## 2. Структура записи ## 2. Структура записи
+81
View File
@@ -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`.
- Другие типы связей в эту ленту не попадают.
@@ -334,6 +334,25 @@
### 8.2. Новые методы, которые нужны ### 8.2. Новые методы, которые нужны
### 8.3. Серверный слой диалогов
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
- отдельная запись на пару `owner_login` + `peer_login`;
- `relation_flag` со значениями `close_friend`, `contact`, `none`;
- `last_message_blob_b64` как последний контентный signed DM block в base64;
- `last_message_time_ms`;
- `unread_count`;
- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения.
Ключевые правила:
- `close_friend` всегда имеет приоритет над `contact`;
- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад;
- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`;
- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции;
- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
## 9. Правила валидации и применения ## 9. Правила валидации и применения
### 9.1. Общее правило по ревизиям ### 9.1. Общее правило по ревизиям
@@ -252,6 +252,15 @@ ReadReceiptBody_v1_0
Различается только `messageType` и формат `body`. Различается только `messageType` и формат `body`.
### Серверное примечание
Внешний байтовый формат `type=3/4` не меняется, но сервер использует такие контейнеры как вход для обновления `dm_dialog_state`:
- `read receipt` обновляет серверный watermark диалога;
- `unreadCount` пересчитывается на сервере, а не только на клиенте;
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
## 9. Контент типов `5/6` ## 9. Контент типов `5/6`
Типы: Типы:
+310 -5
View File
@@ -12,13 +12,13 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260819190000'; window.__SHINE_BUILD_HASH__ = '20260822140000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev'); const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css']; const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
cssFiles.forEach((file) => { cssFiles.forEach((file) => {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
@@ -52,8 +52,284 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';
</script> </script>
<script> <script>
(function attachBootErrorOverlay() { (function attachBootErrorOverlay() {
const show = (title, text) => { const stateKey = '__SHINE_BOOT_ERROR_STATE__';
const menuId = 'boot-error-action-sheet';
const escapeText = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const safeString = (value, maxLen = 1000) => {
const text = String(value == null ? '' : value).trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
};
const setState = (next) => {
try { try {
window[stateKey] = next;
} catch {}
};
const getState = () => {
try {
return window[stateKey] || null;
} catch {
return null;
}
};
const getKnownClientError = () => {
try {
return typeof window.__SHINE_GET_LAST_CLIENT_ERROR__ === 'function'
? window.__SHINE_GET_LAST_CLIENT_ERROR__()
: null;
} catch {
return null;
}
};
const showFeedback = (message, kind = 'success') => {
try {
if (typeof window.__SHINE_SHOW_TOAST__ === 'function') {
window.__SHINE_SHOW_TOAST__(message, kind);
} else {
console.info(message);
}
} catch {}
};
const buildReport = () => {
const state = getState() || {};
const known = getKnownClientError();
const viewport = {
width: Math.round(window.innerWidth || 0),
height: Math.round(window.innerHeight || 0),
dpr: Number(window.devicePixelRatio || 1),
visualWidth: Math.round(window.visualViewport?.width || 0),
visualHeight: Math.round(window.visualViewport?.height || 0),
visualScale: Number(window.visualViewport?.scale || 1),
};
const screenInfo = window.screen ? {
width: Math.round(window.screen.width || 0),
height: Math.round(window.screen.height || 0),
availWidth: Math.round(window.screen.availWidth || 0),
availHeight: Math.round(window.screen.availHeight || 0),
pixelDepth: Number(window.screen.pixelDepth || 0),
} : null;
return {
kind: safeString(state.kind || 'boot_error', 64),
title: safeString(state.title || 'BOOT ERROR', 128),
message: safeString(state.message || '', 500),
stack: safeString(state.stack || '', 8000),
sourceUrl: safeString(state.sourceUrl || '', 240),
lineNumber: Number.isFinite(state.lineNumber) ? state.lineNumber : null,
columnNumber: Number.isFinite(state.columnNumber) ? state.columnNumber : null,
reasonType: safeString(state.reasonType || '', 64),
route: safeString(window.location.hash || window.location.pathname || '', 200),
href: safeString(window.location.href || '', 240),
pageTitle: safeString(document.title || '', 200),
pageVisibility: safeString(document.visibilityState || '', 32),
userAgent: safeString(navigator.userAgent || '', 240),
locale: safeString(navigator.language || '', 32),
clientTs: Number.isFinite(state.clientTs) ? state.clientTs : Date.now(),
viewport,
screenInfo,
lastKnownClientError: known || null,
contextJson: safeString(JSON.stringify({
bootState: state,
currentRoute: window.location.hash || window.location.pathname || '',
hasClientErrorSender: typeof window.__SHINE_SEND_CLIENT_ERROR__ === 'function',
}), 2000),
};
};
const buildSendPayload = (report) => {
const payload = {
kind: safeString(report?.kind || 'boot_error', 64),
message: safeString(report?.message || report?.title || 'Неизвестная ошибка', 500),
stack: safeString(report?.stack || '', 8000),
sourceUrl: safeString(report?.sourceUrl || '', 240),
lineNumber: Number.isFinite(report?.lineNumber) ? report.lineNumber : null,
columnNumber: Number.isFinite(report?.columnNumber) ? report.columnNumber : null,
route: safeString(report?.route || '', 200),
href: safeString(report?.href || '', 240),
userAgent: safeString(report?.userAgent || '', 240),
clientTs: Number.isFinite(report?.clientTs) ? report.clientTs : Date.now(),
requestOp: '',
requestIdRef: '',
contextJson: safeString(JSON.stringify({
...report,
contextJson: undefined,
}), 2000),
};
return payload;
};
const shareErrorText = async (report) => {
const text = formatReportText(report);
if (!text) throw new Error('Текст ошибки пуст');
if (!navigator.share) {
throw new Error('Отправка через системное меню недоступна в этом браузере');
}
try {
await navigator.share({
title: report?.title || 'Описание ошибки',
text,
});
} catch (error) {
if (error?.name === 'AbortError') return false;
throw error;
}
return true;
};
const formatReportText = (report) => {
const lines = [
`Ошибка: ${report.title || report.kind || 'unknown'}`,
`Описание: ${report.message || '—'}`,
`Окно: ${report.pageTitle || '—'}`,
`Маршрут: ${report.route || '—'}`,
`URL: ${report.href || '—'}`,
`Видимость: ${report.pageVisibility || '—'}`,
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
`UA: ${report.userAgent || '—'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
`Источник: ${report.sourceUrl || '—'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
`Тип причины: ${report.reasonType || '—'}`,
];
if (report.stack) {
lines.push('Stack:', report.stack);
}
if (report.lastKnownClientError) {
lines.push('Последняя известная ошибка:', JSON.stringify(report.lastKnownClientError, null, 2));
}
if (report.contextJson) {
lines.push('Контекст:', report.contextJson);
}
return lines.join('\n');
};
const copyText = async (text) => {
const value = String(text || '');
if (!value) return false;
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
};
const removeMenu = () => {
try {
document.getElementById(menuId)?.remove();
} catch {}
};
const openMenu = () => {
try {
removeMenu();
const report = buildReport();
const root = document.getElementById('modal-root') || document.body;
const shell = document.createElement('div');
shell.id = menuId;
shell.className = 'modal-shell boot-error-menu-shell';
shell.innerHTML = `
<div class="modal-backdrop" data-action="close"></div>
<div class="modal-dialog boot-error-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="boot-error-menu-title" tabindex="-1">
<div class="modal-card stack boot-error-menu-card">
<strong class="modal-title" id="boot-error-menu-title">Описание ошибки</strong>
<p class="meta-muted boot-error-menu-message">${escapeText(report.message || report.title || 'Ошибка')}</p>
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
</div>
</div>
`;
root.append(shell);
const dialog = shell.querySelector('.modal-dialog');
const close = () => {
window.removeEventListener('keydown', onKeyDown);
shell.remove();
};
const onKeyDown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
shell.addEventListener('click', (event) => {
if (event.target === shell || event.target?.dataset?.action === 'close') {
close();
}
});
shell.querySelector('[data-action="copy"]')?.addEventListener('click', async () => {
try {
await copyText(formatReportText(report));
showFeedback('Описание ошибки скопировано');
} catch {
showFeedback('Не удалось скопировать описание ошибки', 'error');
}
close();
});
shell.querySelector('[data-action="share"]')?.addEventListener('click', async () => {
try {
const shared = await shareErrorText(report);
if (shared === false) return close();
showFeedback('Текст ошибки открыт для отправки');
} catch (error) {
showFeedback(error?.message || 'Не удалось открыть системное меню отправки', 'error');
}
close();
});
shell.querySelector('[data-action="send"]')?.addEventListener('click', async () => {
try {
const sender = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
if (typeof sender !== 'function') {
throw new Error('Отправка в лог на сервере недоступна');
}
const ok = await sender(buildSendPayload(report));
if (!ok) {
throw new Error('Не удалось отправить ошибку в лог на сервере');
}
showFeedback('Ошибка отправлена в лог на сервере');
} catch (error) {
showFeedback(error?.message || 'Не удалось отправить ошибку в лог на сервере', 'error');
}
close();
});
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
dialog?.focus?.();
window.addEventListener('keydown', onKeyDown);
} catch (error) {
console.warn('boot error menu failed', error);
}
};
const show = (title, text, extra = {}) => {
try {
const reportState = {
kind: safeString(extra.kind || title || 'boot_error', 64),
title: safeString(title || 'BOOT ERROR', 128),
message: safeString(extra.message || text || '', 500),
stack: safeString(extra.stack || '', 8000),
sourceUrl: safeString(extra.sourceUrl || extra.filename || '', 240),
lineNumber: Number.isFinite(extra.lineNumber) ? extra.lineNumber : (Number.isFinite(extra.lineno) ? extra.lineno : null),
columnNumber: Number.isFinite(extra.columnNumber) ? extra.columnNumber : (Number.isFinite(extra.colno) ? extra.colno : null),
reasonType: safeString(extra.reasonType || '', 64),
clientTs: Number.isFinite(extra.clientTs) ? extra.clientTs : Date.now(),
};
setState(reportState);
let el = document.getElementById('boot-error-overlay'); let el = document.getElementById('boot-error-overlay');
if (!el) { if (!el) {
el = document.createElement('pre'); el = document.createElement('pre');
@@ -72,17 +348,46 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';
el.style.lineHeight = '1.4'; el.style.lineHeight = '1.4';
el.style.zIndex = '999999'; el.style.zIndex = '999999';
el.style.whiteSpace = 'pre-wrap'; el.style.whiteSpace = 'pre-wrap';
el.style.cursor = 'pointer';
el.style.userSelect = 'none';
el.style.webkitUserSelect = 'none';
el.style.touchAction = 'manipulation';
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-haspopup', 'dialog');
document.body.appendChild(el); document.body.appendChild(el);
el.addEventListener('click', openMenu);
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openMenu();
}
});
} }
el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`; el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
el.setAttribute('aria-label', `${title}. Нажмите, чтобы открыть меню действий.`);
el.title = 'Нажмите, чтобы открыть меню действий';
} catch {} } catch {}
}; };
window.addEventListener('error', (e) => { window.addEventListener('error', (e) => {
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`); show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
kind: 'window_error',
message: e?.message || '',
stack: e?.error?.stack || '',
sourceUrl: e?.filename || '',
lineNumber: e?.lineno,
columnNumber: e?.colno,
reasonType: e?.error?.constructor?.name || e?.constructor?.name || 'ErrorEvent',
});
}); });
window.addEventListener('unhandledrejection', (e) => { window.addEventListener('unhandledrejection', (e) => {
const reason = e?.reason; const reason = e?.reason;
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown')); show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'), {
kind: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
stack: reason?.stack || '',
reasonType: reason?.constructor?.name || typeof reason,
});
}); });
}()); }());
</script> </script>
+39 -7
View File
@@ -5,7 +5,13 @@ import {
syncTrackedRouteHistory, syncTrackedRouteHistory,
} from './router.js'; } from './router.js';
import { renderToolbar } from './components/toolbar.js'; import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; import {
captureClientError,
getLastClientErrorPayload,
setClientErrorSentNotifier,
setClientErrorTransport,
} from './services/client-error-reporter.js';
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'; import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js';
@@ -80,17 +86,17 @@ import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js'; import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js'; import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js'; import * as messagesList from './pages/messages-list.js?v=202608221218';
import * as contactSearchView from './pages/contact-search-view.js'; import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608191738'; import * as chatView from './pages/chat-view.js?v=202608221218';
import * as userProfileView from './pages/user-profile-view.js'; import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js'; import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
import * as channelThreadView from './pages/channel-thread-view.js'; import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js'; import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
import * as networkView from './pages/network-view.js'; import * as networkView from './pages/network-view.js?v=202608221226';
import * as notificationsView from './pages/notifications-view.js'; import * as notificationsView from './pages/notifications-view.js?v=202608221354';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается'; const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
@@ -190,6 +196,15 @@ let initialConnectionCompleted = false;
let orientationLockInFlight = false; let orientationLockInFlight = false;
let currentChromeCleanup = null; let currentChromeCleanup = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'messages-list',
'chat-view',
'channels-list',
'channel-view',
'channel-thread-view',
'notifications-view',
]);
const GUEST_ALLOWED_PAGES = new Set([ const GUEST_ALLOWED_PAGES = new Set([
'start-view', 'start-view',
'entry-settings-view', 'entry-settings-view',
@@ -208,6 +223,10 @@ setClientErrorSentNotifier((payload) => {
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString(); const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`); showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
}); });
window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ = (payload) => authService.reportClientError(payload);
window.__SHINE_SEND_CLIENT_ERROR__ = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
window.__SHINE_GET_LAST_CLIENT_ERROR__ = () => getLastClientErrorPayload();
window.__SHINE_SHOW_TOAST__ = (message, kind = 'success') => showToast(message, { kind });
initPwaInstallPromptHandling(); initPwaInstallPromptHandling();
initCallUiOverlay(); initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload)); setCallDebugReporter((payload) => authService.reportClientDebug(payload));
@@ -1128,6 +1147,14 @@ function renderPageFailureFallback(pageId, error) {
refreshConnectionUi(); refreshConnectionUi();
} }
function attachPageScrollToBottom(pageId, screen) {
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
return attachScrollToBottomButton({
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
});
}
function renderApp() { function renderApp() {
syncTrackedRouteHistory(window.location.pathname || '/'); syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute(); const route = getRoute();
@@ -1164,7 +1191,12 @@ function renderApp() {
} }
screenEl.append(screen); screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
currentCleanup = () => {
pageCleanup?.();
scrollToBottomControl?.cleanup();
};
screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
+33 -10
View File
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
return (cleanLogin[0] || '?').toUpperCase(); return (cleanLogin[0] || '?').toUpperCase();
} }
export function renderUserAvatar({ export function renderAvatar({
login, initials = '?',
firstName = '',
lastName = '',
avatar = null, avatar = null,
size = 'large', size = 'large',
className = '', className = '',
title = '', title = '',
alt = 'Аватар',
glow = false,
} = {}) { } = {}) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
const classes = ['avatar', 'avatar-image']; const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
const sizeClass = pickSizeClass(size); const sizeClass = pickSizeClass(size);
if (sizeClass) classes.push(sizeClass); if (sizeClass) classes.add(sizeClass);
const extraClass = String(className || '').trim(); const extraClass = String(className || '').trim();
if (extraClass) classes.push(...extraClass.split(/\s+/g)); if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
wrap.className = classes.join(' '); if (glow) classes.add('avatar-glow');
wrap.className = Array.from(classes).join(' ');
if (title) wrap.title = String(title); if (title) wrap.title = String(title);
const fallback = document.createElement('span'); const fallback = document.createElement('span');
fallback.className = 'avatar-fallback'; fallback.className = 'avatar-fallback';
fallback.textContent = buildAvatarInitials({ login, firstName, lastName }); fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
wrap.append(fallback); wrap.append(fallback);
const txId = String(avatar?.ar || '').trim(); const txId = String(avatar?.ar || '').trim();
@@ -56,7 +57,8 @@ export function renderUserAvatar({
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : ''; const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
const img = document.createElement('img'); const img = document.createElement('img');
img.alt = 'Аватар'; img.className = 'avatar-photo';
img.alt = String(alt || 'Аватар');
img.loading = 'lazy'; img.loading = 'lazy';
img.decoding = 'async'; img.decoding = 'async';
wrap.append(img); wrap.append(img);
@@ -131,3 +133,24 @@ export function renderUserAvatar({
return wrap; return wrap;
} }
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
avatar = null,
size = 'large',
className = '',
title = '',
glow = false,
} = {}) {
return renderAvatar({
initials: buildAvatarInitials({ login, firstName, lastName }),
avatar,
size,
className,
title,
alt: 'Аватар',
glow,
});
}
+18 -5
View File
@@ -1,14 +1,20 @@
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) { export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
const wrap = document.createElement('header'); const wrap = document.createElement('header');
wrap.className = 'page-header'; wrap.className = 'page-header app-topbar-shell';
const left = document.createElement('div'); const left = document.createElement('div');
left.className = 'header-left'; left.className = 'header-left';
if (leftAction) { if (leftAction) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'icon-btn'; const rawLabel = String(leftAction.label || '').trim();
btn.textContent = leftAction.label; const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '';
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
btn.textContent = isBackAction ? '←' : rawLabel;
if (isBackAction) {
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
btn.title = leftAction.title || 'Назад';
}
btn.addEventListener('click', leftAction.onClick); btn.addEventListener('click', leftAction.onClick);
left.append(btn); left.append(btn);
} }
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
left.append(label); left.append(label);
} }
const center = document.createElement('div');
center.className = 'header-center';
if (centerNode instanceof Node) {
center.append(centerNode);
} else {
const h1 = document.createElement('h1'); const h1 = document.createElement('h1');
h1.className = 'page-title'; h1.className = 'page-title';
h1.textContent = title; h1.textContent = title;
center.append(h1);
}
const right = document.createElement('div'); const right = document.createElement('div');
right.className = 'header-actions'; right.className = 'header-actions';
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
right.append(btn); right.append(btn);
}); });
wrap.append(left, h1, right); wrap.append(left, center, right);
return wrap; return wrap;
} }
+10
View File
@@ -0,0 +1,10 @@
export function createOverflowDots({ className = '' } = {}) {
const dots = document.createElement('span');
const extra = String(className || '').trim();
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
dots.setAttribute('aria-hidden', 'true');
for (let i = 0; i < 3; i += 1) {
dots.append(document.createElement('i'));
}
return dots;
}
@@ -0,0 +1,144 @@
function resolveElement(value) {
return typeof value === 'function' ? value() : value;
}
function buildArrowIcon() {
const icon = document.createElement('span');
icon.className = 'scroll-to-bottom-btn__icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '↓';
return icon;
}
export function attachScrollToBottomButton({
scrollContainer,
mountTarget = document.querySelector('.app-shell'),
thresholdPx = 160,
title = 'Вниз',
} = {}) {
const target = resolveElement(mountTarget);
if (!(target instanceof Element)) {
return {
button: null,
refresh() {},
cleanup() {},
};
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'scroll-to-bottom-btn';
button.title = title;
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
button.tabIndex = -1;
button.append(buildArrowIcon());
target.append(button);
let disposed = false;
let boundContainer = null;
let resizeObserver = null;
let mutationObserver = null;
let refreshFrame = 0;
const hide = () => {
button.classList.remove('is-visible');
button.setAttribute('aria-hidden', 'true');
button.tabIndex = -1;
};
const scheduleRefresh = () => {
if (disposed || refreshFrame) return;
refreshFrame = window.requestAnimationFrame(() => {
refreshFrame = 0;
refresh();
});
};
const unbindContainer = () => {
boundContainer?.removeEventListener('scroll', scheduleRefresh);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
resizeObserver = null;
mutationObserver = null;
boundContainer = null;
};
const bindContainer = () => {
const nextContainer = resolveElement(scrollContainer);
if (!(nextContainer instanceof Element)) {
if (boundContainer) unbindContainer();
return null;
}
if (nextContainer === boundContainer) return boundContainer;
unbindContainer();
boundContainer = nextContainer;
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
if (typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(scheduleRefresh);
resizeObserver.observe(boundContainer);
}
if (typeof MutationObserver === 'function') {
mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
}
return boundContainer;
};
function refresh() {
if (disposed) return;
const container = bindContainer();
if (!container) {
hide();
return;
}
const scrollHeight = Number(container.scrollHeight || 0);
const clientHeight = Number(container.clientHeight || 0);
const scrollTop = Number(container.scrollTop || 0);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
button.classList.toggle('is-visible', shouldShow);
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
button.tabIndex = shouldShow ? 0 : -1;
}
const scrollToBottom = () => {
const container = bindContainer();
if (!container) return;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
} else {
container.scrollTop = container.scrollHeight;
}
scheduleRefresh();
};
button.addEventListener('click', scrollToBottom);
window.addEventListener('resize', scheduleRefresh);
window.visualViewport?.addEventListener('resize', scheduleRefresh);
window.requestAnimationFrame(refresh);
return {
button,
refresh: scheduleRefresh,
cleanup() {
if (disposed) return;
disposed = true;
if (refreshFrame) {
window.cancelAnimationFrame(refreshFrame);
refreshFrame = 0;
}
unbindContainer();
window.removeEventListener('resize', scheduleRefresh);
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
button.removeEventListener('click', scrollToBottom);
button.remove();
},
};
}
+10 -11
View File
@@ -10,7 +10,7 @@ import {
openArweaveAttachmentManager, openArweaveAttachmentManager,
markArweaveAttachmentPlaced, markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js'; } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; import { renderAvatar } from '../components/avatar-image.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' }; export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
function renderAvatarPreview(slot, avatar, title) { function renderAvatarPreview(slot, avatar, title) {
if (!slot) return; if (!slot) return;
slot.innerHTML = ''; slot.innerHTML = '';
const wrap = document.createElement('div'); const label = String(title || 'К').trim() || 'К';
wrap.className = 'channel-profile-avatar'; const wrap = renderAvatar({
initials: label.slice(0, 1).toUpperCase() || 'К',
avatar,
size: 'small',
className: 'channel-profile-avatar',
title: label,
alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', '104px'); wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap); slot.append(wrap);
} }
+3 -1
View File
@@ -6,6 +6,7 @@ import {
readArweaveAttachmentHistory, readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js'; } from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js'; import { formatBytes } from '../services/attachment-format.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js'; import { state } from '../state.js';
@@ -73,13 +74,14 @@ export function render({ navigate }) {
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад"></button> <button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад"></button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div> <div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button> <button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button> <button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden> <div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button> <button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
<button class="text-btn" type="button" data-action="clear">Очистить историю</button> <button class="text-btn" type="button" data-action="clear">Очистить историю</button>
<button class="text-btn" type="button" data-action="help">Справка</button> <button class="text-btn" type="button" data-action="help">Справка</button>
</div> </div>
`; `;
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
const statusLine = document.createElement('p'); const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error'; statusLine.className = 'meta-muted inline-error';
+22 -23
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js'; import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js'; import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean'); appScreen?.classList.add('channels-scroll-clean');
const header = renderHeader({ const threadHeaderButton = document.createElement('button');
title: '',
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [],
});
header.classList.add('channel-thread-topbar');
const headerLeft = header.querySelector('.header-left');
let threadHeaderButton = null;
if (headerLeft) {
const channelsListButton = document.createElement('button');
channelsListButton.type = 'button';
channelsListButton.className = 'icon-btn';
channelsListButton.textContent = '↑';
channelsListButton.title = 'К списку каналов';
channelsListButton.setAttribute('aria-label', 'К списку каналов');
channelsListButton.addEventListener('click', () => navigate('channels-list'));
headerLeft.append(channelsListButton);
threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button'; threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn'; threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...'; threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true; threadHeaderButton.disabled = true;
headerLeft.append(threadHeaderButton);
} const header = renderHeader({
centerNode: threadHeaderButton,
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header); chrome?.setTopbar(header);
const statusBox = document.createElement('div'); const statusBox = document.createElement('div');
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--thread'); const current = document.querySelector('section.channels-screen--thread');
if (!current) return; if (!current) return;
const next = render({ navigate, route }); const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next); current.replaceWith(next);
} catch (error) { } catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname }); logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
invalid.className = 'card meta-muted'; invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.'; invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid); screen.append(invalid);
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen; return screen;
} }
+247 -45
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { import {
authService, authService,
getMessageReactionState, getMessageReactionState,
@@ -16,7 +16,7 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
@@ -31,7 +31,6 @@ import {
makeProfileRoute, makeProfileRoute,
makeShineMessageRoute, makeShineMessageRoute,
} from '../services/shine-routes.js'; } from '../services/shine-routes.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
@@ -237,9 +236,200 @@ function buildThreadRoute(messageRef, selector) {
function buildChannelSettingsKey(ownerBlockchainName, channelName) { function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(ownerBlockchainName || '').trim(); const ownerBch = String(ownerBlockchainName || '').trim();
const name = String(channelName || '').trim(); const name = String(channelName || '').trim();
if (!ownerBch || !name) return '';
return `${ownerBch}/${name}`; return `${ownerBch}/${name}`;
} }
function getChannelScrollRoot() {
return document.getElementById('app-screen');
}
function scrollRootBy(delta, smooth = false) {
const root = getChannelScrollRoot();
const behavior = smooth ? 'smooth' : 'auto';
if (root && typeof root.scrollBy === 'function') {
root.scrollBy({ top: delta, behavior });
return;
}
window.scrollBy({ top: delta, behavior });
}
function getUnreadAnchorViewportFraction(unreadCount = 0) {
const count = Math.max(0, Number(unreadCount || 0));
if (count <= 1) return 0.68;
if (count <= 3) return 0.56;
if (count <= 7) return 0.48;
return 0.42;
}
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
if (!element) return false;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
const rect = element.getBoundingClientRect();
const delta = rect.top - targetTop;
if (Math.abs(delta) < 2) return true;
scrollRootBy(delta, smooth);
return true;
}
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
return scrollElementToViewportFraction(
screen.querySelector('.channel-unread-line'),
getUnreadAnchorViewportFraction(unreadCount),
smooth,
);
}
function createChannelReadTracker({
screen,
routeKey,
settingKey,
unreadCount,
messagesCount,
initialSeenCount,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const canWrite = !!(settingKey && login && storagePwd);
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let inFlight = false;
let disposed = false;
let rafId = 0;
let timerId = 0;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
};
const queueFlush = (delayMs = 180) => {
if (disposed || !canWrite) return;
clearTimer();
timerId = setTimeout(() => {
timerId = 0;
void flush();
}, Math.max(0, Number(delayMs) || 0));
};
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return;
if (inFlight) {
queueFlush(120);
return;
}
inFlight = true;
try {
await authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd,
});
persistedSeenCount = next;
} catch {
queueFlush(800);
} finally {
inFlight = false;
}
};
const collectSeenCount = () => {
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
if (!cards.length) return safeInitialSeenCount;
if (!unreadLine) return safeMessagesCount;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
let seen = safeInitialSeenCount;
for (const card of cards) {
const localNumber = Number(card.dataset.localNumber || 0);
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
const rect = card.getBoundingClientRect();
if (rect.top > thresholdTop + 1) break;
seen = Math.max(seen, localNumber);
}
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
};
const measure = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = collectSeenCount();
if (next > desiredSeenCount) {
desiredSeenCount = next;
queueFlush(180);
}
});
};
const scrollRoot = getChannelScrollRoot();
const onScroll = () => measure();
const onResize = () => measure();
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
} else {
window.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
if (initialSyncRequired) {
void authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: safeMessagesCount,
storagePwd,
}).catch(() => {});
persistedSeenCount = safeMessagesCount;
desiredSeenCount = safeMessagesCount;
} else {
window.setTimeout(() => measure(), 120);
}
const cleanup = () => {
disposed = true;
clearTimer();
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
scrollRoot.removeEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
};
return {
cleanup,
measure,
};
}
function firstNonEmptyText(...candidates) { function firstNonEmptyText(...candidates) {
for (const candidate of candidates) { for (const candidate of candidates) {
if (typeof candidate !== 'string') continue; if (typeof candidate !== 'string') continue;
@@ -387,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
} }
function createChannelAvatarElement(channel, size = 72) { function createChannelAvatarElement(channel, size = 72) {
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
const txId = String(channel?.avaAr || '').trim(); const txId = String(channel?.avaAr || '').trim();
if (txId) { const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
const img = document.createElement('img'); const wrap = renderAvatar({
img.alt = 'Аватар канала'; initials: title.slice(0, 1).toUpperCase() || 'К',
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId }); avatar: txId ? { ar: txId } : null,
wrap.append(img); size: 'small',
} else { className: 'channel-profile-avatar',
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К'; title,
} alt: 'Аватар канала',
});
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
return wrap; return wrap;
} }
@@ -1370,7 +1559,7 @@ async function loadFromApi(route, channelId) {
throw new Error('Канал не найден.'); throw new Error('Канал не найден.');
} }
unreadCount = Number(channel?.unreadCount || 0); unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); messagesCount = Number(channel?.messagesCount || 0);
selector = { selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName), ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
@@ -1447,6 +1636,7 @@ async function loadFromApi(route, channelId) {
return { return {
channel: { channel: {
name: payload.channel?.channelName || 'неизвестный канал', name: payload.channel?.channelName || 'неизвестный канал',
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(), displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`, displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(), description: String(payload.channel?.channelDescription || '').trim(),
@@ -1749,6 +1939,9 @@ function renderPostCard(post, {
if (refKey) { if (refKey) {
card.dataset.messageKey = refKey; card.dataset.messageKey = refKey;
} }
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
card.dataset.localNumber = String(Number(post.localNumber));
}
card.classList.add('is-counters-visible'); card.classList.add('is-counters-visible');
if (!post.messageRef || !selector) return card; if (!post.messageRef || !selector) return card;
@@ -1914,6 +2107,10 @@ function renderPostCard(post, {
} }
function renderBody(screen, navigate, routeKey, channelData, handlers) { function renderBody(screen, navigate, routeKey, channelData, handlers) {
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
const readCount = Math.max(0, messagesCount - unreadCount);
if (channelData.reverseChannelMissingWarning) { if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p'); const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta'; reverseWarning.className = 'channel-head-meta';
@@ -1921,13 +2118,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(reverseWarning); screen.append(reverseWarning);
} }
if (Number(channelData.unreadCount || 0) > 0) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
screen.append(unreadLine);
}
const actionButton = document.createElement('button'); const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action'; actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал'; actionButton.textContent = 'Подписаться на канал';
@@ -1946,6 +2136,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const postsByKey = new Map(); const postsByKey = new Map();
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : []) const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
.map((event) => mapChannelMetaEvent(event, channelData.channel)); .map((event) => mapChannelMetaEvent(event, channelData.channel));
let unreadLineInserted = unreadCount === 0;
const feedItems = [ const feedItems = [
...metaEvents.map((event) => ({ ...metaEvents.map((event) => ({
type: 'meta', type: 'meta',
@@ -1967,6 +2158,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (feedItems.length) { if (feedItems.length) {
feedItems.forEach((item) => { feedItems.forEach((item) => {
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = 'Не прочитанные сообщения';
feed.append(unreadLine);
unreadLineInserted = true;
}
if (item.type === 'meta') { if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event)); feed.append(renderChannelMetaEventCard(item.event));
return; return;
@@ -2018,10 +2216,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton); screen.append(feed, backButton);
} }
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0); const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
return () => { applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
// noop if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
}; window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
unreadCount,
messagesCount,
initialSeenCount: readCount,
});
return tracker.cleanup;
} }
function renderSkeleton(screen) { function renderSkeleton(screen) {
@@ -2056,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = ''; statusBox.style.display = '';
}; };
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
const header = renderHeader({ const header = renderHeader({
title: '', centerNode: channelHeaderButton,
leftAction: { label: '<', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [ rightActions: [
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
], ],
}); });
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn'); const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
if (channelHeaderButton) {
channelHeaderButton.disabled = true;
}
if (channelEntrypointButton) { if (channelEntrypointButton) {
channelEntrypointButton.disabled = true; channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true; channelEntrypointButton.hidden = true;
@@ -2079,6 +2293,7 @@ export function render({ navigate, route, chrome }) {
const current = document.querySelector('section.channels-screen--channel'); const current = document.querySelector('section.channels-screen--channel');
if (!current) return; if (!current) return;
const next = render({ navigate, route }); const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next); current.replaceWith(next);
}; };
let activeSelector = null; let activeSelector = null;
@@ -2317,19 +2532,6 @@ export function render({ navigate, route, chrome }) {
try { try {
const apiData = await loadFromApi(route, channelId); const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null; activeSelector = apiData?.selector || null;
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
void authService.upsertUserSetting({
login: state.session.login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: lastSeenCount,
storagePwd: state.session.storagePwdInMemory,
}).catch(() => {});
}
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel'; const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts); const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => { const openEntrypointHistory = () => {
+24 -88
View File
@@ -1,4 +1,4 @@
import { channels as mockChannels } from '../mock-data.js'; import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js'; import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js'; import { parseMessageAttachments } from '../services/attachment-format.js';
@@ -12,8 +12,8 @@ import {
writeChannelNotificationsState, writeChannelNotificationsState,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js'; import { makeShineChannelRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js'; import { renderAvatar } from '../components/avatar-image.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; import { createOverflowDots } from '../components/overflow-dots.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
return first.toUpperCase(); return first.toUpperCase();
} }
function createChannelAvatar(channel = {}) {
return renderAvatar({
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'small',
title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала',
});
}
function allFeedSummaries() { function allFeedSummaries() {
const feed = state.channelsFeed || {}; const feed = state.channelsFeed || {};
return [ return [
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'channel-row'; row.className = 'channel-row';
row.innerHTML = ` row.innerHTML = `
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
<div class="channel-row-main"> <div class="channel-row-main">
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong> <strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p> <p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
<span class="channel-row-time"></span> <span class="channel-row-time"></span>
</div> </div>
`; `;
row.prepend(createChannelAvatar(channel));
row.addEventListener('click', () => { row.addEventListener('click', () => {
const route = channel.route || makeShineChannelRoute({ const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'), ownerLogin: String(channel.ownerName || 'channel'),
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
listState, listState,
anchorEl, anchorEl,
navigate, navigate,
onSubscribeChannel,
onFindChannel, onFindChannel,
}) { }) {
closeTopChannelsMenu(listState); closeTopChannelsMenu(listState);
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
let left = rect.right - menuWidth; let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12)); left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 320; const estimatedHeight = 250;
let top = rect.bottom + 8; let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) { if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8); top = Math.max(12, rect.top - estimatedHeight - 8);
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
menu.style.width = `${Math.round(menuWidth)}px`; menu.style.width = `${Math.round(menuWidth)}px`;
const items = [ const items = [
{ label: 'Поиск', action: () => onFindChannel?.() },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) }, { label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) }, { label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) }, { label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
{ divider: true },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
]; ];
items.forEach((item) => { items.forEach((item) => {
@@ -1206,20 +1213,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const countersVisible = listState.revealedCounters.has(channel.id); const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible); row.classList.toggle('is-counters-visible', countersVisible);
const avatar = document.createElement('div'); const avatar = createChannelAvatar(channel);
avatar.className = 'avatar';
if (channel.avaAr) {
const img = document.createElement('img');
img.alt = '';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
avatar.append(img);
} else {
avatar.textContent = channel.avatar;
}
const main = renderChannelMain(channel); const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.className = 'channel-row-controls'; controls.className = 'channel-row-controls';
@@ -1230,37 +1227,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const count = document.createElement('span'); const count = document.createElement('span');
count.className = 'unread channel-row-count'; count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0); const unreadCount = Number(channel.unreadCount || 0);
count.hidden = unreadCount <= 0;
count.textContent = unreadCount > 0 ? String(unreadCount) : ''; count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0); count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger';
menuButton.textContent = '…';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(menuButton);
listState.revealedCounters.add(channel.id);
if (listState.openMenuId === channel.id) {
closeChannelMenu(listState);
rerenderList();
return;
}
listState.openMenuId = channel.id;
openChannelMenu({
listState,
channel,
anchorEl: menuButton,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
rerenderList,
});
rerenderList();
});
controls.append(menuButton);
}
controls.append(time, count); controls.append(time, count);
row.append(avatar, main, controls); row.append(avatar, main, controls);
@@ -1373,43 +1343,18 @@ export function render({ navigate, route, chrome }) {
const topBarLeft = document.createElement('div'); const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left'; topBarLeft.className = 'channels-top-left';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.className = 'icon-btn channels-top-back-btn';
backBtn.textContent = '←';
backBtn.setAttribute('aria-label', 'Назад');
backBtn.addEventListener('click', () => navigateBack());
const topTitle = document.createElement('strong'); const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title'; topTitle.className = 'channels-top-title';
const topBarRight = document.createElement('div'); const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right'; topBarRight.className = 'channels-top-right';
const findChannelBtn = document.createElement('button');
findChannelBtn.type = 'button';
findChannelBtn.className = 'icon-btn channels-top-search-btn';
findChannelBtn.setAttribute('aria-label', 'Найти канал');
findChannelBtn.title = 'Найти канал';
const findChannelIcon = document.createElement('span');
findChannelIcon.className = 'channels-search-icon';
findChannelIcon.setAttribute('aria-hidden', 'true');
findChannelBtn.append(findChannelIcon);
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn';
createInMyBtn.textContent = '+';
createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
const topMenuBtn = document.createElement('button'); const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button'; topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn'; topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия'); topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.title = 'Ещё действия'; topMenuBtn.title = 'Ещё действия';
topMenuBtn.textContent = '⋮'; topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => { topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(topMenuBtn); animatePress(topMenuBtn);
@@ -1418,18 +1363,11 @@ export function render({ navigate, route, chrome }) {
anchorEl: topMenuBtn, anchorEl: topMenuBtn,
navigate, navigate,
onFindChannel: () => openChannelFinderModal({ navigate }), onFindChannel: () => openChannelFinderModal({ navigate }),
onSubscribeChannel: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Добавить канал',
submitLabel: 'Добавить',
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
}),
}); });
}); });
topBarLeft.append(backBtn, topTitle); topBarRight.append(topMenuBtn);
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn); topBarEl.append(topBarLeft, topTitle, topBarRight);
topBarEl.append(topBarLeft, topBarRight);
const bottomCta = document.createElement('button'); const bottomCta = document.createElement('button');
bottomCta.type = 'button'; bottomCta.type = 'button';
@@ -1450,10 +1388,8 @@ export function render({ navigate, route, chrome }) {
}); });
topTitle.textContent = channelsViewTitle(listState.viewMode); topTitle.textContent = channelsViewTitle(listState.viewMode);
findChannelBtn.style.display = '';
createInMyBtn.style.display = '';
topMenuBtn.style.display = ''; topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle); if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta }); updateBottomCta({ button: bottomCta });
}; };
+54 -7
View File
@@ -1,4 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { import {
addAppLogEntry, addAppLogEntry,
@@ -27,10 +29,55 @@ import {
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js'; import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function createChatHeaderParts(login) {
const cleanLogin = String(login || '').trim() || 'unknown';
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
login: cleanLogin,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
const loginEl = document.createElement('span');
loginEl.className = 'chat-header-login';
loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.textContent = cleanLogin;
void loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
if (!avatarSlot.isConnected) return;
const upgradedAvatar = renderUserAvatar({
login: cleanLogin,
firstName: String(snapshot?.firstName || '').trim(),
lastName: String(snapshot?.lastName || '').trim(),
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
})
.catch(() => {});
return { centerNode: loginEl, avatarSlot };
}
function truncatePreviewText(value, maxLen = 72) { function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim(); const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return ''; if (!normalized) return '';
@@ -906,7 +953,6 @@ export function render({ navigate, route, chrome }) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap'; wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div'); const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader'; historyLoader.className = 'dm-history-loader';
historyLoader.hidden = true; historyLoader.hidden = true;
@@ -920,9 +966,9 @@ export function render({ navigate, route, chrome }) {
const log = document.createElement('div'); const log = document.createElement('div');
log.className = 'messages-log dm-messages-log'; log.className = 'messages-log dm-messages-log';
chrome?.setTopbar( const chatHeaderParts = createChatHeaderParts(chatId);
renderHeader({ const chatHeader = renderHeader({
title: `Чат с ${contact.name}`, centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') }, leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [ rightActions: [
{ {
@@ -933,7 +979,7 @@ export function render({ navigate, route, chrome }) {
onClick: () => handleStartCall('audio'), onClick: () => handleStartCall('audio'),
}, },
{ {
label: '⋮', iconNode: createOverflowDots(),
title: 'Действия чата', title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата', ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn', className: 'chat-header-icon-btn chat-header-menu-btn',
@@ -990,8 +1036,9 @@ export function render({ navigate, route, chrome }) {
}, },
}, },
], ],
}), });
); chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
chrome?.setTopbar(chatHeader);
if (!isKnownContact) { if (!isKnownContact) {
const card = document.createElement('div'); const card = document.createElement('div');
+151 -79
View File
@@ -1,21 +1,29 @@
import { directMessages } from '../mock-data.js';
import { import {
getChatMessages, authService,
isSessionInvalidError, isSessionInvalidError,
normalizeDmChatId, normalizeDmChatId,
setContacts, setContacts,
state, state,
terminateCurrentSession, terminateCurrentSession,
} from '../state.js'; } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map(); const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map(); const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([
['close_friend', 0],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) { async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null; if (!cleanLogin) return null;
@@ -37,13 +45,14 @@ async function loadDmAvatarSnapshot(login) {
return pending; return pending;
} }
function createDmAvatar(login) { function createDmAvatar(login, { className = '' } = {}) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({ const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown', login: cleanLogin || 'unknown',
size: 'small', size: 'small',
title, title,
className,
}); });
if (!cleanLogin) return avatarEl; if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
@@ -58,6 +67,7 @@ function createDmAvatar(login) {
: null, : null,
size: 'small', size: 'small',
title, title,
className,
}); });
upgraded.classList.add('avatar'); upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded); avatarEl.replaceWith(upgraded);
@@ -65,10 +75,72 @@ function createDmAvatar(login) {
return avatarEl; return avatarEl;
} }
function resolveLastMessagePreview(text = '') { function normalizeRelationFlag(value) {
const parsed = parseDmTechBlocks(String(text || '')); const clean = String(value || '').trim().toLowerCase();
const display = String(parsed.displayText || '').trim(); if (clean === 'close_friend' || clean === 'contact') return clean;
return display || ''; return 'none';
}
function relationOrder(flag) {
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
}
function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'contact':
return 'контакт';
default:
return 'не в контактах';
}
}
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}`;
}
async function resolveDialogPreview(dialog) {
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
if (!blobB64) return 'Диалог пока пуст.';
const cacheKey = [
blobB64,
String(state.session.login || '').trim().toLowerCase(),
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
const pending = (async () => {
try {
const parsed = authService.parseSignedMessageBlob(blobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
blobB64,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
const display = clipPreviewText(String(parsedText.displayText || '').trim());
const result = display || 'Сообщение';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = 'Сообщение недоступно';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
}
})();
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
return pending;
} }
function formatChatRowTime(ts) { function formatChatRowTime(ts) {
@@ -83,21 +155,17 @@ function formatChatRowTime(ts) {
} }
function compareChatRows(a, b) { function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0); const timeA = Number(a?.lastMessageTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0); const timeB = Number(b?.lastMessageTimeMs || 0);
if (timeA !== timeB) return timeB - timeA; if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase(); const nameA = String(a?.peerLogin || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase(); const nameB = String(b?.peerLogin || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru'); 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 }) { export function render({ navigate, chrome }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen'; screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
const head = document.createElement('header'); const head = document.createElement('header');
head.className = 'dm-head'; head.className = 'dm-head';
head.innerHTML = ` head.innerHTML = `
@@ -107,27 +175,24 @@ export function render({ navigate, chrome }) {
<span class="dm-head-name"></span> <span class="dm-head-name"></span>
</div> </div>
</div> </div>
<h1 class="dm-head-title">Контакты</h1> <h1 class="dm-head-title">Чаты</h1>
<div class="dm-head-menu-wrap"> <div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false"> <button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden> <div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts"> <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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle> <circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path> <path d="M16 16l4 4"></path>
</svg> </svg>
<span>Поиск контактов</span> <span>Поиск пользователей</span>
</button> </button>
</div> </div>
</div> </div>
`; `;
const headName = head.querySelector('.dm-head-name'); const menuButton = head.querySelector('.dm-head-menu-btn');
if (headName) headName.textContent = login; menuButton?.append(createOverflowDots());
const menuWrap = head.querySelector('.dm-head-menu-wrap'); const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu'); const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the // 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 // header bounds. A dropdown overflowing below it can look visible but clicks may
@@ -164,7 +229,7 @@ export function render({ navigate, chrome }) {
<circle cx="11" cy="11" r="6.5"></circle> <circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path> <path d="M16 16l4 4"></path>
</svg> </svg>
<span>Поиск контактов</span> <span>Поиск пользователей</span>
</button> </button>
`; `;
@@ -206,16 +271,17 @@ export function render({ navigate, chrome }) {
window.addEventListener('resize', onMenuViewportChange, { passive: true }); window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true }); window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack dm-list'; list.className = 'stack dm-list';
function renderRow(item) { function renderRow(item) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id); const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin);
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.className = 'dm-av dm-av--default';
@@ -224,14 +290,14 @@ export function render({ navigate, chrome }) {
<div class="dm-row-main"> <div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap"> <div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong> <strong class="dm-row-title"></strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''} <span class="dm-contact-note">${relationBadge}</span>
</div> </div>
<p class="dm-row-last-message"></p> <p class="dm-row-last-message"></p>
</div> </div>
<div class="dm-row-meta-col"> <div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'} ${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line"> <div class="dm-row-meta-line">
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'} ${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span> <span class="dm-chevron">${SVG_CHEVRON}</span>
</div> </div>
</div> </div>
@@ -239,11 +305,15 @@ export function render({ navigate, chrome }) {
const titleEl = row.querySelector('.dm-row-title'); const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message'); const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time'); const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.name || ''); if (titleEl) titleEl.textContent = String(item.peerLogin || '');
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.'; if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = String(item.time || ''); if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap); row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`)); void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row; return row;
} }
@@ -257,62 +327,64 @@ export function render({ navigate, chrome }) {
} }
try { try {
const relations = await loadCurrentRelations(); const payload = await authService.listContacts();
const contacts = relations.outContacts || []; const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
setContacts(contacts); setContacts(contacts);
list.innerHTML = ''; list.innerHTML = '';
const contactRows = contacts.map((login) => { const byPeer = new Map();
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); dialogs.forEach((dialog) => {
const canonicalLogin = normalizeDmChatId(login); const peerLogin = String(dialog?.peerLogin || '').trim();
const chat = getChatMessages(canonicalLogin); if (!peerLogin) return;
const lastChat = chat[chat.length - 1]; const key = peerLogin.toLowerCase();
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
const lastTimeMs = Number(lastChat?.createdAtMs || 0); const next = {
return { id: peerLogin,
id: canonicalLogin, peerLogin,
name: preview?.name || login, relationFlag,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
time: formatChatRowTime(lastTimeMs), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unread, unreadCount: Number(dialog?.unreadCount || 0),
notInContacts: false, hasDialog: Boolean(dialog?.hasDialog),
lastTimeMs,
}; };
const current = byPeer.get(key);
if (!current) {
byPeer.set(key, next);
return;
}
const currentRank = relationOrder(current.relationFlag);
const nextRank = relationOrder(relationFlag);
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
byPeer.set(key, next);
}
}); });
const allChatIds = Object.keys(state.chats || {}) const rows = Array.from(byPeer.values()).sort((a, b) => {
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) const orderA = relationOrder(a.relationFlag);
.filter((id) => (getChatMessages(id) || []).length > 0); const orderB = relationOrder(b.relationFlag);
if (orderA !== orderB) return orderA - orderB;
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); return compareChatRows(a, b);
const extraRows = allChatIds
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
.map((login) => {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
lastTimeMs,
};
}); });
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
if (!rows.length) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ни контактов, ни сообщений'; empty.textContent = 'Пока нет диалогов';
list.append(empty); list.append(empty);
return; return;
} }
rows.forEach((item) => list.append(renderRow(item))); let dividerInserted = false;
rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item));
});
} catch (error) { } catch (error) {
if (isSessionInvalidError(error)) { if (isSessionInvalidError(error)) {
list.innerHTML = ''; list.innerHTML = '';
@@ -352,7 +424,7 @@ export function render({ navigate, chrome }) {
} }
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(divider, list); screen.append(list);
loadList(); loadList();
screen.cleanup = () => { screen.cleanup = () => {
+8 -24
View File
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
let persistedCenterLogin = ''; let persistedCenterLogin = '';
let persistedCenterHistory = []; let persistedCenterHistory = [];
export function render({ navigate, route }) { export function render({ navigate, route, chrome } = {}) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || ''); const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) { if (!keepHistory) {
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
else window.history.replaceState({}, '', nextPath); else window.history.replaceState({}, '', nextPath);
} }
function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0;
}
function openSearchModal() { function openSearchModal() {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred); if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
persistHistory(); persistHistory();
setBackButtonState(backBtnEl);
} catch (error) { } catch (error) {
if (requestId !== loadSeq) return; if (requestId !== loadSeq) return;
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
const header = renderHeader({ const header = renderHeader({
title: 'Связи', title: 'Связи',
leftAction: {
label: '←',
onClick: () => {
if (!centerHistory.length) return;
const prev = centerHistory.pop();
if (!prev) {
setBackButtonState(backBtnEl);
return;
}
void load(prev, { pushHistory: false });
},
},
rightActions: [ rightActions: [
{ label: 'Найти', onClick: openSearchModal }, { label: 'Найти', onClick: openSearchModal },
], ],
}); });
const backBtnEl = header.querySelector('.header-left .icon-btn'); // «Связи» используют тот же общий topbar, что и остальные страницы.
setBackButtonState(backBtnEl); // Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
header.classList.add('network-topbar');
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри). // Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => { screen.cleanup = () => {
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
window.setTimeout(() => openSearchModal(), 0); window.setTimeout(() => openSearchModal(), 0);
} }
} }
setBackButtonState(backBtnEl);
// Панель фильтров слоёв (оверлей под шапкой) // Панель фильтров слоёв (оверлей под шапкой)
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar'; filterBar.className = 'fg-filter-bar app-top-tabs';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
filterBar.append(chip); filterBar.append(chip);
}); });
header.classList.add('network-header-overlay'); chrome?.setTopbar(header);
stage.append(board, header, filterBar); stage.append(board, filterBar);
screen.append(stage); screen.append(stage);
return screen; return screen;
} }
+370 -32
View File
@@ -1,56 +1,394 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
const CONNECTION_CLOSE_FRIEND = 10;
const profileSnapshotCache = new Map();
const profileSnapshotPending = new Map();
function connectionTypeLabel(typeCode) {
switch (Number(typeCode)) {
case CONNECTION_CLOSE_FRIEND:
return 'близкие друзья';
default:
return 'новую связь';
}
}
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' }; export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
function renderList(container) { function normalizeItem(item) {
const active = state.notificationsTab; return {
container.innerHTML = ''; kind: String(item?.kind || ''),
const card = document.createElement('article'); createdAtMs: Number(item?.createdAtMs || 0),
card.className = 'card stack'; sourceLogin: String(item?.sourceLogin || ''),
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
const title = document.createElement('strong'); sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке'; sourceBlockHash: String(item?.sourceBlockHash || ''),
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
const description = document.createElement('p'); sourceText: String(item?.sourceText || ''),
description.className = 'meta-muted'; connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
description.textContent = active === 'events' targetLogin: String(item?.targetLogin || ''),
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.' targetBlockchainName: String(item?.targetBlockchainName || ''),
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.'; targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
targetBlockHash: String(item?.targetBlockHash || ''),
const note = document.createElement('p'); profile: null,
note.className = 'meta-muted'; engagement: null,
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.'; };
card.append(title, description, note);
container.append(card);
} }
export function render({ chrome } = {}) { function formatRelativeTime(value) {
const ts = Number(value || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diffMs = Math.max(0, Date.now() - ts);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
if (diffMs < minute) return 'сейчас';
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
}
function profileField(snapshot, key) {
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
.find((field) => String(field?.key || '') === key);
return String(row?.value || '').trim();
}
async function loadCachedProfileSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
profileSnapshotCache.set(key, snapshot || null);
profileSnapshotPending.delete(key);
return snapshot || null;
})
.catch(() => {
profileSnapshotCache.set(key, null);
profileSnapshotPending.delete(key);
return null;
});
profileSnapshotPending.set(key, pending);
return pending;
}
function normalizeEngagement(source) {
if (!source || typeof source !== 'object') return null;
const likesCount = Math.max(0, Number(source.likesCount || 0));
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
const result = {
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
};
return Object.values(result).some((count) => count > 0) ? result : null;
}
async function loadSourceEngagement(item) {
const blockchainName = String(item?.sourceBlockchainName || '').trim();
const blockNumber = Number(item?.sourceBlockNumber);
const blockHash = String(item?.sourceBlockHash || '').trim();
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
try {
const payload = await authService.getMessageThread(
{ blockchainName, blockNumber, blockHash },
0,
0,
1,
String(state.session.login || '').trim(),
);
return normalizeEngagement(payload?.focus);
} catch {
return null;
}
}
async function enrichItem(item, activeTab) {
const [profile, engagement] = await Promise.all([
loadCachedProfileSnapshot(item.sourceLogin),
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
]);
return { ...item, profile, engagement };
}
function renderEmpty(activeTab) {
const card = document.createElement('article');
card.className = 'card stack notification-empty-state';
const title = document.createElement('strong');
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = activeTab === 'events'
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
card.append(title, text);
return card;
}
function renderIdentity(item) {
const profile = item.profile;
const firstName = profileField(profile, 'first_name');
const lastName = profileField(profile, 'last_name');
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
const avatar = profile?.avatar?.txId
? {
ar: String(profile.avatar.txId || '').trim(),
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
}
: null;
const header = document.createElement('div');
header.className = 'notification-identity';
header.append(renderUserAvatar({
login: item.sourceLogin || 'unknown',
firstName,
lastName,
avatar,
size: 'small',
className: 'notification-avatar',
}));
const text = document.createElement('div');
text.className = 'notification-identity-text';
const primary = document.createElement('div');
primary.className = 'notification-identity-primary';
const name = document.createElement('strong');
name.className = 'notification-person-name';
name.textContent = fullName;
primary.append(name);
const login = String(item.sourceLogin || '').trim();
if (login) {
const loginEl = document.createElement('span');
loginEl.className = 'notification-login';
loginEl.textContent = `@${login}`;
primary.append(loginEl);
}
const relative = formatRelativeTime(item.createdAtMs);
if (relative) {
const separator = document.createElement('span');
separator.className = 'notification-time-separator';
separator.textContent = '·';
const time = document.createElement('span');
time.className = 'notification-time';
time.textContent = relative;
primary.append(separator, time);
}
text.append(primary);
header.append(text);
return header;
}
function renderEngagement(engagement) {
if (!engagement) return null;
const stats = [
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
].filter(({ key }) => Number(engagement[key] || 0) > 0);
if (!stats.length) return null;
const row = document.createElement('div');
row.className = 'notification-engagement';
stats.forEach(({ key, icon, label }) => {
const stat = document.createElement('span');
stat.className = 'notification-engagement-item';
stat.title = label;
const iconEl = document.createElement('span');
iconEl.className = 'notification-engagement-icon';
iconEl.setAttribute('aria-hidden', 'true');
iconEl.textContent = icon;
const countEl = document.createElement('span');
countEl.className = 'notification-engagement-count';
countEl.textContent = String(engagement[key]);
stat.append(iconEl, countEl);
row.append(stat);
});
return row;
}
function notificationRoute(item, activeTab) {
if (activeTab === 'events') {
const login = String(item?.sourceLogin || '').trim();
return login ? makeProfileRoute(login) : '';
}
const blockchainName = String(item?.sourceBlockchainName || '').trim();
const blockNumber = Number(item?.sourceBlockNumber);
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
return makeShineMessageRoute({
messageBlockchainName: blockchainName,
messageBlockNumber: blockNumber,
});
}
function bindNotificationNavigation(row, routePath, navigate) {
if (!routePath || typeof navigate !== 'function') return;
row.classList.add('notification-card--clickable');
row.tabIndex = 0;
row.setAttribute('role', 'link');
const open = () => navigate(routePath);
row.addEventListener('click', open);
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
open();
});
}
function renderItem(item, activeTab, navigate) {
const row = document.createElement('article');
row.className = 'card stack notification-card';
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
row.append(renderIdentity(item));
const action = document.createElement('p');
action.className = 'notification-action';
if (activeTab === 'events') {
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
} else {
action.textContent = 'Ответил(а) на ваше сообщение.';
}
row.append(action);
if (activeTab === 'replies') {
const body = document.createElement('p');
body.className = 'notification-content';
body.textContent = item.sourceText || 'Ответ без текста.';
row.append(body);
const engagement = renderEngagement(item.engagement);
if (engagement) row.append(engagement);
}
return row;
}
export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack notifications-screen'; screen.className = 'stack notifications-screen';
chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div'); const tabs = document.createElement('div');
tabs.className = 'tabs'; tabs.className = 'notification-feed-tabs app-top-tabs';
tabs.innerHTML = ` tabs.innerHTML = `
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button> <button
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button> type="button"
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
data-tab="replies"
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
>Ответы</button>
<button
type="button"
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
data-tab="events"
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
>События</button>
`; `;
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack notifications-list'; list.className = 'stack notifications-list';
renderList(list);
tabs.querySelectorAll('.tab-btn').forEach((btn) => { let requestSeq = 0;
async function load() {
const seq = ++requestSeq;
const activeTab = state.notificationsTab;
list.replaceChildren(renderEmpty(activeTab));
try {
const payload = await authService.getNotifications(50);
if (seq !== requestSeq) return;
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
.map(normalizeItem);
if (!baseItems.length) {
list.replaceChildren(renderEmpty(activeTab));
return;
}
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
if (seq !== requestSeq) return;
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
} catch (error) {
if (seq !== requestSeq) return;
const card = document.createElement('article');
card.className = 'card stack';
const title = document.createElement('strong');
title.textContent = 'Не удалось загрузить уведомления';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = error?.message || 'Ошибка запроса к серверу';
card.append(title, text);
list.replaceChildren(card);
}
}
function setActiveNotificationTab(nextTab) {
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
state.notificationsTab = normalizedTab;
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
const selected = node.dataset.tab === normalizedTab;
node.classList.toggle('is-active', selected);
node.dataset.selected = selected ? 'true' : 'false';
node.setAttribute('aria-selected', selected ? 'true' : 'false');
});
}
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
setActiveNotificationTab(state.notificationsTab);
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
state.notificationsTab = btn.dataset.tab; const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active')); if (state.notificationsTab === nextTab) {
btn.classList.add('active'); setActiveNotificationTab(nextTab);
renderList(list); return;
}
setActiveNotificationTab(nextTab);
void load();
}); });
}); });
screen.append(tabs, list); screen.append(tabs, list);
void load();
return screen; return screen;
} }
+85 -14
View File
@@ -1,4 +1,4 @@
import { profile } from '../mock-data.js'; import { profile } from '../mock-data.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { import {
PROFILE_GENDER_FEMALE, PROFILE_GENDER_FEMALE,
@@ -7,6 +7,7 @@ import {
} from '../services/user-profile-params.js'; } from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js'; import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
const topbar = document.createElement('header'); const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile'; topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = ` topbar.innerHTML = `
<div class="header-actions profile-top-actions"> <div class="header-left" aria-hidden="true"></div>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль"> <div class="header-center"><h1 class="page-title">Профиль</h1></div>
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" /> <div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
</button> <button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</button> </button>
</div> </div>
`; `;
const topActions = topbar.querySelector('.profile-top-actions');
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view')); const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view')); const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view')); profileMenuButton?.append(createOverflowDots());
let profileMenuPortal = null;
const closeProfileMenu = () => {
profileMenuPortal?.remove();
profileMenuPortal = null;
profileMenuButton?.setAttribute('aria-expanded', 'false');
profileMenuWrap?.classList.remove('is-open');
};
const positionProfileMenu = () => {
if (!profileMenuPortal || !profileMenuButton) return;
const rect = profileMenuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = profileMenuPortal.offsetWidth || 224;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
profileMenuPortal.style.left = `${Math.round(left)}px`;
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openProfileMenu = () => {
if (!profileMenuButton || profileMenuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Редактировать профиль</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
<span>Кошелёк</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
<span>Настройки</span>
</button>
`;
const goTo = (route) => {
closeProfileMenu();
navigate(route);
};
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
profileMenuPortal = portal;
profileMenuButton.setAttribute('aria-expanded', 'true');
profileMenuWrap?.classList.add('is-open');
positionProfileMenu();
};
profileMenuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (profileMenuPortal) closeProfileMenu();
else openProfileMenu();
});
document.addEventListener('click', (event) => {
if (!profileMenuPortal) return;
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
closeProfileMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !profileMenuPortal) return;
closeProfileMenu();
profileMenuButton?.focus();
});
window.addEventListener('resize', positionProfileMenu, { passive: true });
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
chrome?.setTopbar(topbar); chrome?.setTopbar(topbar);
const card = document.createElement('div'); const card = document.createElement('div');
+8 -2
View File
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js'; import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
checkLoginExistsOnSolana, checkLoginExistsOnSolana,
@@ -426,7 +426,13 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Зарегистрироваться', title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') }, leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}), }),
form, form,
actions, actions,
+12 -2
View File
@@ -3,6 +3,7 @@ import {
authService, authService,
authorizeSession, authorizeSession,
refreshSessions, refreshSessions,
resetRegistrationFlow,
setAuthError, setAuthError,
setAuthInfo, setAuthInfo,
state, state,
@@ -102,7 +103,10 @@ export function render({ navigate }) {
cancelButton.className = 'ghost-btn'; cancelButton.className = 'ghost-btn';
cancelButton.type = 'button'; cancelButton.type = 'button';
cancelButton.textContent = 'Отмена'; cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view')); cancelButton.addEventListener('click', () => {
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button'); const okButton = document.createElement('button');
okButton.className = 'primary-btn'; okButton.className = 'primary-btn';
@@ -190,7 +194,13 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Сохранение ключей', title: 'Сохранение ключей',
leftAction: { label: '←', onClick: () => navigate('start-view') }, leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}), }),
card, card,
actions, actions,
@@ -3,6 +3,7 @@ import {
authService, authService,
authorizeSession, authorizeSession,
refreshSessions, refreshSessions,
resetRegistrationFlow,
setAuthError, setAuthError,
setAuthInfo, setAuthInfo,
state, state,
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
replacement.addEventListener('click', () => { replacement.addEventListener('click', () => {
stageClosed = true; stageClosed = true;
stopTimers(); stopTimers();
resetRegistrationFlow();
navigate('start-view'); navigate('start-view');
}); });
headerBackButton.replaceWith(replacement); headerBackButton.replaceWith(replacement);
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
replacement.addEventListener('click', () => { replacement.addEventListener('click', () => {
loginCompleted = true; loginCompleted = true;
stopAutoLogin(); stopAutoLogin();
resetRegistrationFlow();
navigate('start-view'); navigate('start-view');
}); });
headerBackButton.replaceWith(replacement); headerBackButton.replaceWith(replacement);
+29 -1
View File
@@ -509,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
}; };
} }
function extractContactsFromDialogs(dialogs) {
const seen = new Set();
const out = [];
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
const login = String(dialog?.peerLogin || '').trim();
if (!login) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) { function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || '')); const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || '')); const valueBytes = utf8Bytes(String(value || ''));
@@ -2852,7 +2868,11 @@ export class AuthService {
async listContacts() { async listContacts() {
const response = await this.ws.request('ListContacts', {}); const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response); if (response.status !== 200) throw opError('ListContacts', response);
return response.payload || {}; const payload = response.payload || {};
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
payload.contacts = extractContactsFromDialogs(payload.dialogs);
}
return payload;
} }
@@ -2862,6 +2882,14 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async getNotifications(limit = 50) {
const payload = {};
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
const response = await this.ws.request('GetNotifications', payload);
if (response.status !== 200) throw opError('GetNotifications', response);
return response.payload || {};
}
async getUserConnectionsGraph(login) { async getUserConnectionsGraph(login) {
const response = await this.ws.request('GetUserConnectionsGraph', { login }); const response = await this.ws.request('GetUserConnectionsGraph', { login });
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response); if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
@@ -6,6 +6,7 @@ let transport = null;
let transportDepth = 0; let transportDepth = 0;
const recentFingerprints = new Map(); const recentFingerprints = new Map();
let notifySent = null; let notifySent = null;
let lastCapturedPayload = null;
function nowTs() { function nowTs() {
return Date.now(); return Date.now();
@@ -85,6 +86,11 @@ export function setClientErrorSentNotifier(fn) {
notifySent = typeof fn === 'function' ? fn : null; notifySent = typeof fn === 'function' ? fn : null;
} }
export function getLastClientErrorPayload() {
if (!lastCapturedPayload) return null;
return { ...lastCapturedPayload };
}
export function isClientErrorReportingEnabled() { export function isClientErrorReportingEnabled() {
try { try {
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1'; return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
@@ -104,6 +110,7 @@ export function setClientErrorReportingEnabled(enabled) {
export async function captureClientError(details = {}) { export async function captureClientError(details = {}) {
const payload = buildPayload(details); const payload = buildPayload(details);
if (!payload.message) return false; if (!payload.message) return false;
lastCapturedPayload = payload;
const fingerprint = details.dedupeKey || makeFingerprint(payload); const fingerprint = details.dedupeKey || makeFingerprint(payload);
if (isDuplicate(fingerprint)) return false; if (isDuplicate(fingerprint)) return false;
+8
View File
@@ -925,6 +925,14 @@ export async function refreshSessions() {
return state.sessions; return state.sessions;
} }
export function resetRegistrationFlow() {
const next = createInitialState();
state.registrationDraft = next.registrationDraft;
state.registrationHelp = next.registrationHelp;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
}
function resetStateForSignedOut() { function resetStateForSignedOut() {
const next = createInitialState({ withStoredSession: false }); const next = createInitialState({ withStoredSession: false });
state.chats = next.chats; state.chats = next.chats;
+223
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -198,7 +198,7 @@
} }
.fg-orb-host .fg-pngorb-init { .fg-orb-host .fg-pngorb-init {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px; background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
} }
.fg-node.is-family .node-dot { .fg-node.is-family .node-dot {
@@ -422,7 +422,7 @@
/* Панель фильтров слоёв (оверлей под шапкой) */ /* Панель фильтров слоёв (оверлей под шапкой) */
.fg-filter-bar { .fg-filter-bar {
position: absolute; position: absolute;
top: max(54px, calc(env(safe-area-inset-top) + 50px)); top: max(72px, calc(env(safe-area-inset-top) + 68px));
left: 0; left: 0;
right: 0; right: 0;
z-index: 11; z-index: 11;