Compare commits

..
Author SHA256 Message Date
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
AidarKC 23bdadab56 Merge branch 'расширение-функционала-каналов' 2026-08-20 08:23:19 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
39 changed files with 2262 additions and 267 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,7 @@ 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 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 +36,7 @@ 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";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -53,10 +55,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 +130,10 @@ 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);
} }
} }
} }
@@ -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
@@ -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;
@@ -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) {
@@ -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-логику только ради этого редкого сценария.
+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 |
+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`.
- Другие типы связей в эту ленту не попадают.
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
/> />
<base href="/" /> <base href="/" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
@@ -12,7 +12,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260806223040'; window.__SHINE_BUILD_HASH__ = '20260819190000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
+106 -1
View File
@@ -82,7 +82,7 @@ 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';
import * as contactSearchView from './pages/contact-search-view.js'; import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202607152145'; import * as chatView from './pages/chat-view.js?v=202608191738';
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';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
@@ -221,6 +221,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
setShellMetricVar('--keyboard-offset', valuePx); setShellMetricVar('--keyboard-offset', valuePx);
} }
let stableViewportHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || window.visualViewport?.height || 0),
);
function isTextEntryFocused() {
const active = document.activeElement;
return active instanceof HTMLTextAreaElement
|| (active instanceof HTMLInputElement && !['button', 'checkbox', 'radio', 'range', 'file', 'submit', 'reset'].includes(active.type));
}
function syncViewportMetrics() {
if (!appShellEl) return;
const viewport = window.visualViewport || null;
const currentLayoutHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || viewport?.height || 0),
);
const widthPx = Math.max(1, Math.round(window.innerWidth || viewport?.width || 0));
const offsetLeftPx = Math.max(0, Math.round(viewport?.offsetLeft || 0));
const textEntryFocused = isTextEntryFocused();
// Android Chrome/Firefox могут уменьшать и visualViewport, и innerHeight.
// Поэтому высоту экрана до открытия клавиатуры запоминаем отдельно и во
// время ввода НЕ переписываем ею app-shell. Иначе toolbar тоже поднимется.
if (!textEntryFocused) {
stableViewportHeightPx = Math.max(stableViewportHeightPx, currentLayoutHeightPx);
// После поворота/реального resize разрешаем уменьшить базу, но только
// когда никакое текстовое поле не держит экранную клавиатуру.
if (Math.abs(stableViewportHeightPx - currentLayoutHeightPx) > 220) {
stableViewportHeightPx = currentLayoutHeightPx;
}
} else if (currentLayoutHeightPx > stableViewportHeightPx) {
stableViewportHeightPx = currentLayoutHeightPx;
}
const visualBottomPx = viewport
? Math.round((viewport.offsetTop || 0) + viewport.height)
: currentLayoutHeightPx;
const rawKeyboardOffsetPx = Math.max(
0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
);
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx
: 0;
setShellMetricVar('--app-viewport-width', widthPx);
setShellMetricVar('--app-viewport-height', keyboardOffsetPx > 0 ? stableViewportHeightPx : currentLayoutHeightPx);
setShellMetricVar('--app-viewport-offset-top', 0);
setShellMetricVar('--app-viewport-offset-left', offsetLeftPx);
setKeyboardOffsetPx(keyboardOffsetPx);
appShellEl.classList.toggle('keyboard-open', keyboardOffsetPx > 0);
}
function attachSlotHeightObserver(slotEl, cssVarName) { function attachSlotHeightObserver(slotEl, cssVarName) {
if (!slotEl || typeof ResizeObserver !== 'function') return null; if (!slotEl || typeof ResizeObserver !== 'function') return null;
const sync = () => { const sync = () => {
@@ -237,6 +293,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height'); const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height'); const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
syncViewportMetrics();
window.visualViewport?.addEventListener('resize', syncViewportMetrics);
window.visualViewport?.addEventListener('scroll', syncViewportMetrics);
window.addEventListener('resize', syncViewportMetrics);
document.addEventListener('focusin', () => {
requestAnimationFrame(syncViewportMetrics);
// Samsung One UI / Firefox can finish the OSK viewport transition several
// frames after focus. Re-sample through the animation instead of trusting
// the first resize event.
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 180);
window.setTimeout(syncViewportMetrics, 320);
});
document.addEventListener('focusout', () => {
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 220);
});
// Optional on-device viewport diagnostics: append ?keyboard-debug=1 to the URL.
if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
const debugEl = document.createElement('pre');
debugEl.id = 'keyboard-viewport-debug';
Object.assign(debugEl.style, {
position: 'fixed', top: '4px', right: '4px', zIndex: '999999', margin: '0',
maxWidth: '94vw', padding: '6px 8px', fontSize: '10px', lineHeight: '1.25',
color: '#fff', background: 'rgba(0,0,0,.82)', pointerEvents: 'none',
whiteSpace: 'pre-wrap',
});
document.body.append(debugEl);
const syncDebug = () => {
const vv = window.visualViewport;
debugEl.textContent = [
`innerHeight=${window.innerHeight}`,
`clientHeight=${document.documentElement.clientHeight}`,
`vv.height=${vv ? Math.round(vv.height) : 'n/a'}`,
`vv.offsetTop=${vv ? Math.round(vv.offsetTop) : 'n/a'}`,
`stable=${stableViewportHeightPx}`,
`keyboard=${getComputedStyle(appShellEl).getPropertyValue('--keyboard-offset').trim()}`,
`focused=${isTextEntryFocused()}`,
].join(' | ');
};
window.visualViewport?.addEventListener('resize', syncDebug);
window.visualViewport?.addEventListener('scroll', syncDebug);
window.addEventListener('resize', syncDebug);
document.addEventListener('focusin', () => window.setTimeout(syncDebug, 330));
document.addEventListener('focusout', () => window.setTimeout(syncDebug, 230));
syncDebug();
}
function clearSlot(slotEl, cssVarName) { function clearSlot(slotEl, cssVarName) {
if (!slotEl) return; if (!slotEl) return;
slotEl.innerHTML = ''; slotEl.innerHTML = '';
@@ -0,0 +1,141 @@
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.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');
};
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');
}
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();
},
};
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.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';
@@ -1143,6 +1144,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack channels-screen channels-screen--thread'; screen.className = 'stack channels-screen channels-screen--thread';
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 scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
const header = renderHeader({ const header = renderHeader({
title: '', title: '',
@@ -1180,6 +1182,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 +1355,10 @@ 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 = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean');
};
return screen; return screen;
} }
@@ -1538,6 +1545,7 @@ export function render({ navigate, route, chrome }) {
})(); })();
screen.cleanup = () => { screen.cleanup = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean'); appScreen?.classList.remove('channels-scroll-clean');
}; };
+241 -29
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
import { import {
authService, authService,
getMessageReactionState, getMessageReactionState,
@@ -234,12 +235,203 @@ function buildThreadRoute(messageRef, selector) {
}); });
} }
function buildChannelSettingsKey(selector, channelName) { function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(selector?.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;
@@ -1255,6 +1447,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
async function loadFromApi(route, channelId) { async function loadFromApi(route, channelId) {
const currentSessionLogin = String(state.session.login || '').trim(); const currentSessionLogin = String(state.session.login || '').trim();
const isAuthorized = !!currentSessionLogin; const isAuthorized = !!currentSessionLogin;
let unreadCount = 0;
let messagesCount = 0;
let cachedFeed = null; let cachedFeed = null;
const ensureFeed = async () => { const ensureFeed = async () => {
if (cachedFeed) return cachedFeed; if (cachedFeed) return cachedFeed;
@@ -1315,6 +1509,9 @@ async function loadFromApi(route, channelId) {
} }
if (selector?.ownerBlockchainName && selector?.channelName) { if (selector?.ownerBlockchainName && selector?.channelName) {
let unreadCount = 0;
let messagesCount = 0;
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
@@ -1364,6 +1561,8 @@ async function loadFromApi(route, channelId) {
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.'); throw new Error('Канал не найден.');
} }
unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || 0);
selector = { selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName), ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
@@ -1380,8 +1579,7 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : []; const messages = Array.isArray(payload.messages) ? payload.messages : [];
let reverseChannelMissingWarning = ''; let reverseChannelMissingWarning = '';
let mergedMessages = [...messages]; let mergedMessages = [...messages];
const unreadCount = Number(channel?.unreadCount || 0); if (!messagesCount) messagesCount = mergedMessages.length;
const messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0);
const currentLogin = currentSessionLogin; const currentLogin = currentSessionLogin;
const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
@@ -1441,6 +1639,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(),
@@ -1743,6 +1942,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;
@@ -1908,6 +2110,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';
@@ -1915,13 +2121,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 = 'Подписаться на канал';
@@ -1940,6 +2139,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',
@@ -1961,6 +2161,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;
@@ -2012,10 +2219,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton); screen.append(feed, backButton);
} }
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary); const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
return () => { applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
// noop if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
}; window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
unreadCount,
messagesCount,
initialSeenCount: readCount,
});
return tracker.cleanup;
} }
function renderSkeleton(screen) { function renderSkeleton(screen) {
@@ -2035,6 +2257,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack channels-screen channels-screen--channel'; screen.className = 'stack channels-screen channels-screen--channel';
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 scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
const statusBox = document.createElement('div'); const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status'; statusBox.className = 'card status-line is-unavailable channels-status';
@@ -2073,6 +2296,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;
@@ -2311,19 +2535,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?.selector, 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 = () => {
@@ -2479,6 +2690,7 @@ export function render({ navigate, route, chrome }) {
})(); })();
screen.cleanup = () => { screen.cleanup = () => {
scrollToBottomControl.cleanup();
appScreen?.classList.remove('channels-scroll-clean'); appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking(); if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
}; };
+5 -48
View File
@@ -1219,7 +1219,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
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,38 +1229,11 @@ 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.textContent = unreadCount > 0 ? String(unreadCount) : ''; if (unreadCount > 0) {
count.classList.toggle('is-empty', unreadCount <= 0); count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
controls.append(count);
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);
row.append(avatar, main, controls); row.append(avatar, main, controls);
row.addEventListener('click', () => { row.addEventListener('click', () => {
@@ -1278,14 +1250,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list); container.append(list);
} }
function updateBottomCta({ button }) {
if (!button) return;
button.hidden = true;
button.textContent = '';
button.className = 'channels-bottom-action';
button.onclick = null;
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState); closeChannelMenu(listState);
renderSkeletonList(contentEl, 5); renderSkeletonList(contentEl, 5);
@@ -1431,9 +1395,6 @@ export function render({ navigate, route, chrome }) {
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn); topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
topBarEl.append(topBarLeft, topBarRight); topBarEl.append(topBarLeft, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => { const rerenderList = () => {
@@ -1454,19 +1415,15 @@ export function render({ navigate, route, chrome }) {
createInMyBtn.style.display = ''; createInMyBtn.style.display = '';
topMenuBtn.style.display = ''; topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle); if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
updateBottomCta({ button: bottomCta });
}; };
chrome?.setTopbar(topBarEl); chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta); screen.append(contentEl);
if (createSuccessFlash) { if (createSuccessFlash) {
showToast(createSuccessFlash); showToast(createSuccessFlash);
} }
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере, // Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки. // чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList(); rerenderList();
+94 -54
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { import {
addAppLogEntry, addAppLogEntry,
@@ -572,12 +573,27 @@ function scrollToUnreadSeparator(list) {
return true; return true;
} }
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) { function renderLog(
list,
chatId,
{
onOpenActions,
markAsRead = true,
scrollMode = 'latest',
showUnreadSeparator = true,
unreadSeparatorMessageKey = '',
} = {},
) {
list.innerHTML = ''; list.innerHTML = '';
const messages = getChatMessages(chatId); const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false; let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
messages.forEach((msg) => { messages.forEach((msg) => {
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) { const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
if (isUnreadBoundary) {
const sep = document.createElement('div'); const sep = document.createElement('div');
sep.className = 'chat-unread-separator'; sep.className = 'chat-unread-separator';
const label = document.createElement('span'); const label = document.createElement('span');
@@ -781,12 +797,8 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
} }
} }
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
}
export function render({ navigate, route, chrome }) { export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1'; const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || { const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -800,12 +812,53 @@ export function render({ navigate, route, chrome }) {
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true; let historyHasMore = true;
let historyLoading = false; let historyLoading = false;
let historyNextBeforeTimeMs = 0; let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = ''; let historyNextBeforeMessageKey = '';
let historyBootstrapped = false; let historyBootstrapped = false;
let boundScrollContainer = null; let boundScrollContainer = null;
let unreadSeparatorVisible = hasUnreadIncoming;
let unreadSeparatorHideTimer = null;
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
const clearUnreadSeparatorHideTimer = () => {
if (unreadSeparatorHideTimer) {
window.clearTimeout(unreadSeparatorHideTimer);
unreadSeparatorHideTimer = null;
}
};
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead,
scrollMode,
showUnreadSeparator: unreadSeparatorVisible,
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
};
const hideUnreadSeparator = ({ rerender = true } = {}) => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorVisible = false;
if (rerender) {
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
const scheduleUnreadSeparatorAutoHide = () => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorHideTimer = window.setTimeout(() => {
hideUnreadSeparator({ rerender: true });
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
};
const handleReadAloud = async (msg) => { const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) { if (!isTextToSpeechConfigured(state.entrySettings)) {
@@ -819,13 +872,13 @@ export function render({ navigate, route, chrome }) {
const handleStartCall = async (mode = 'audio') => { const handleStartCall = async (mode = 'audio') => {
try { try {
await startOutgoingCall(chatId, { mode }); await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
} catch (e) { } catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out', from: 'out',
kind: 'call-tech', kind: 'call-tech',
}); });
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
} }
}; };
@@ -848,16 +901,15 @@ export function render({ navigate, route, chrome }) {
unread: false, unread: false,
rawBlobB64: String(result?.localBlobB64 || ''), rawBlobB64: String(result?.localBlobB64 || ''),
}); });
renderLog(log, chatId, { renderChatLog({ scrollMode: 'latest', markAsRead: false });
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
}; };
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap'; wrap.className = 'chat-wrap dm-chat-wrap';
const scrollToBottomControl = attachScrollToBottomButton({
scrollContainer: () => boundScrollContainer || wrap,
});
const historyLoader = document.createElement('div'); const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader'; historyLoader.className = 'dm-history-loader';
@@ -1010,29 +1062,6 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false; let inputFocused = false;
let emojiPickerOpen = false; let emojiPickerOpen = false;
let emojiSelection = null; let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const appShell = document.querySelector('.app-shell');
const setKeyboardInset = (valuePx = 0) => {
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
};
const syncKeyboardUi = () => {
const viewport = window.visualViewport || null;
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
const keyboardInset = viewportShrunk
? Math.max(0, baseViewportHeight - viewportHeight)
: 0;
setKeyboardInset(keyboardInset);
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (viewportShrunk && window.scrollY !== 0) {
window.scrollTo(0, 0);
}
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
};
const setHistoryLoadingState = (isLoading) => { const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading; historyLoader.hidden = !isLoading;
@@ -1235,20 +1264,21 @@ export function render({ navigate, route, chrome }) {
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true }); cancelEditMode({ restoreDraft: true });
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
}; };
const sendTextMessage = async (rawText) => { const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || '')); const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim(); const text = safeText.trim();
if (!text) return; if (!text) return;
hideUnreadSeparator({ rerender: false });
const editing = activeEdit; const editing = activeEdit;
const replying = !editing ? activeReply : null; const replying = !editing ? activeReply : null;
const finalText = editing const finalText = editing
? `${String(editing?.prefixText || '')}${text}` ? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`; : `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try { try {
@@ -1288,7 +1318,7 @@ export function render({ navigate, route, chrome }) {
cancelReplyMode({ restoreDraft: false }); cancelReplyMode({ restoreDraft: false });
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) { if (localRevisionApplied) {
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
} }
@@ -1331,7 +1361,7 @@ export function render({ navigate, route, chrome }) {
error: e?.message || 'unknown', error: e?.message || 'unknown',
}, },
}); });
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
} }
}; };
@@ -1393,7 +1423,7 @@ export function render({ navigate, route, chrome }) {
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0); historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim(); historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true; historyBootstrapped = true;
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' }); renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) { if (preserveScroll) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0); const nextHeight = Number(scrollContainer?.scrollHeight || 0);
@@ -1445,12 +1475,12 @@ export function render({ navigate, route, chrome }) {
input?.addEventListener('focus', () => { input?.addEventListener('focus', () => {
rememberEmojiSelection(); rememberEmojiSelection();
inputFocused = true; inputFocused = true;
syncKeyboardUi(); window.requestAnimationFrame(() => {
scrollToLatestMessage(log); if (inputFocused) scrollToLatestMessage(log);
});
}); });
input?.addEventListener('blur', () => { input?.addEventListener('blur', () => {
inputFocused = false; inputFocused = false;
setChatKeyboardOpen(false);
}); });
emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => { emojiToggle?.addEventListener('pointerdown', (event) => {
@@ -1493,25 +1523,34 @@ export function render({ navigate, route, chrome }) {
const handleIncomingChatRefresh = async (event) => { const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId); const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return; if (updatedChatId !== chatId) return;
if (Number(event?.detail?.messageType || 0) === 1) {
if (!unreadSeparatorVisible) {
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
}
scheduleUnreadSeparatorAutoHide();
}
preserveComposerSelection(input, () => { preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' }); renderChatLog({ scrollMode: 'latest' });
}); });
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
}; };
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
chrome?.setComposer(form);
wrap.append(historyLoader, log); wrap.append(historyLoader, log);
screen.append(wrap); screen.append(wrap);
chrome?.setComposer(form);
renderLog(log, chatId, { renderLog(log, chatId, {
onOpenActions: handleOpenActions, onOpenActions: handleOpenActions,
markAsRead: false, markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest', scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
showUnreadSeparator: unreadSeparatorVisible,
}); });
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
window.setTimeout(() => { window.setTimeout(() => {
if (markChatRead(chatId) > 0) { if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
@@ -1519,18 +1558,19 @@ export function render({ navigate, route, chrome }) {
}, 220); }, 220);
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap; boundScrollContainer = wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true }); boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: true }); void loadHistoryPage({ preserveScroll: true });
}); });
screen.cleanup = () => { screen.cleanup = () => {
setChatKeyboardOpen(false); scrollToBottomControl.cleanup();
setKeyboardInset(0); hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations(); stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
}; };
return screen; return screen;
} }
+115 -3
View File
@@ -82,6 +82,15 @@ function formatChatRowTime(ts) {
}).format(new Date(value)); }).format(new Date(value));
} }
function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru');
}
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>'; 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 }) {
@@ -99,11 +108,103 @@ export function render({ navigate, chrome }) {
</div> </div>
</div> </div>
<h1 class="dm-head-title">Контакты</h1> <h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button> <div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
</div>
</div>
`; `;
const headName = head.querySelector('.dm-head-name'); const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login; if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
// header bounds. A dropdown overflowing below it can look visible but clicks may
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div'); const divider = document.createElement('div');
divider.className = 'dm-divider'; divider.className = 'dm-divider';
@@ -175,6 +276,7 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs), time: formatChatRowTime(lastTimeMs),
unread, unread,
notInContacts: false, notInContacts: false,
lastTimeMs,
}; };
}); });
@@ -197,10 +299,11 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs), time: formatChatRowTime(lastTimeMs),
unread, unread,
notInContacts: true, notInContacts: true,
lastTimeMs,
}; };
}); });
const rows = [...contactRows, ...extraRows]; 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';
@@ -251,5 +354,14 @@ export function render({ navigate, chrome }) {
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(divider, list); screen.append(divider, list);
loadList(); loadList();
screen.cleanup = () => {
closeHeadMenu();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen; return screen;
} }
+334 -26
View File
@@ -1,32 +1,304 @@
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';
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: 'Уведомления' }));
@@ -40,17 +312,53 @@ export function render({ chrome } = {}) {
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack notifications-list'; list.className = 'stack notifications-list';
renderList(list);
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);
}
}
tabs.querySelectorAll('.tab-btn').forEach((btn) => { tabs.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
state.notificationsTab = btn.dataset.tab; const nextTab = String(btn.dataset.tab || 'replies');
if (state.notificationsTab === nextTab) return;
state.notificationsTab = nextTab;
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active')); tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
renderList(list); void load();
}); });
}); });
screen.append(tabs, list); screen.append(tabs, list);
void load();
return screen; return screen;
} }
+82 -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,
@@ -101,22 +101,90 @@ 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-actions profile-head-menu-wrap dm-head-menu-wrap">
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль"> <button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" /> <span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
</button>
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
</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')); 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);
+8
View File
@@ -2862,6 +2862,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);
+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;
+293 -22
View File
@@ -4248,6 +4248,36 @@ textarea.input {
gap: 10px; gap: 10px;
} }
.channel-unread-line {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0 12px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(244, 202, 102, 0.46);
background:
linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9));
color: #ffe6a7;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
text-align: center;
justify-content: center;
box-shadow: 0 0 0 1px rgba(255, 226, 155, 0.08), 0 10px 24px rgba(6, 10, 20, 0.35);
}
.channel-unread-line::before,
.channel-unread-line::after {
content: '';
flex: 1 1 0;
height: 1px;
min-width: 18px;
background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent);
}
.channels-screen--channel .channel-feed { .channels-screen--channel .channel-feed {
gap: 2px; gap: 2px;
margin-left: -7px; margin-left: -7px;
@@ -6268,6 +6298,37 @@ html, body { overflow-x: hidden; }
gap: 12px; gap: 12px;
} }
.dm-chat-screen {
display: flex;
flex-direction: column;
min-height: 0;
gap: 12px;
}
.dm-chat-screen > .dm-chat-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.dm-chat-composer {
position: sticky;
bottom: 0;
z-index: 12;
flex: 0 0 auto;
}
.screen-content:has(> .dm-chat-screen) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.screen-content:has(> .dm-chat-screen) > .dm-chat-screen {
flex: 1 1 auto;
min-height: 0;
}
.screen-content:has(> .dm-screen) { .screen-content:has(> .dm-screen) {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -6282,27 +6343,14 @@ html, body { overflow-x: hidden; }
.screen-content:has(> .dm-chat-screen) { .screen-content:has(> .dm-chat-screen) {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
scrollbar-width: thin; scrollbar-width: none;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06); -ms-overflow-style: none;
} }
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar { .screen-content:has(> .dm-chat-screen)::-webkit-scrollbar {
width: 4px; width: 0;
height: 4px; height: 0;
display: block; display: none;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.7);
border-radius: 999px;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb:hover {
background: rgba(240, 198, 76, 0.9);
} }
.dm-messages-log { .dm-messages-log {
@@ -6401,11 +6449,10 @@ html, body { overflow-x: hidden; }
gap: 10px; gap: 10px;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: end; align-items: end;
position: sticky; position: relative;
bottom: 0;
z-index: 10; z-index: 10;
margin-inline: -14px; margin-inline: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); padding: 10px 0;
border-top: 1px solid rgba(212, 175, 55, 0.22); border-top: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(8, 12, 20, 0.9); background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -7900,3 +7947,227 @@ html, body { overflow-x: hidden; }
color: #D4AF37; color: #D4AF37;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
} }
/* Contacts header overflow menu — vertical ellipsis + glass dropdown. */
.dm-head-menu-wrap {
position: relative;
justify-self: end;
display: grid;
place-items: center;
z-index: 40;
}
.dm-head-menu-btn {
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 16px;
display: grid;
place-items: center;
color: #FFD98A;
background: transparent;
cursor: pointer;
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.dm-head-menu-btn:active { transform: scale(0.94); }
.dm-head-menu-wrap.is-open .dm-head-menu-btn,
.dm-head-menu-btn:focus-visible {
outline: none;
background: rgba(240, 184, 46, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 184, 46, 0.24), 0 0 18px rgba(240, 184, 46, 0.12);
}
.dm-head-menu-dots {
width: 6px;
height: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3.5px;
}
.dm-head-menu-dots i {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 7px rgba(240, 184, 46, 0.45);
}
.dm-head-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
width: max-content;
min-width: 190px;
padding: 6px;
border: 1px solid rgba(240, 184, 46, 0.26);
border-radius: 15px;
background: linear-gradient(155deg, rgba(22, 24, 31, 0.97), rgba(10, 12, 18, 0.97));
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42), 0 0 20px rgba(240, 184, 46, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transform-origin: top right;
animation: dm-head-menu-in 140ms ease-out both;
}
.dm-head-menu[hidden] { display: none; }
.dm-head-menu::before {
content: '';
position: absolute;
top: -5px;
right: 18px;
width: 9px;
height: 9px;
transform: rotate(45deg);
border-left: 1px solid rgba(240, 184, 46, 0.22);
border-top: 1px solid rgba(240, 184, 46, 0.22);
background: rgba(19, 21, 28, 0.98);
}
.dm-head-menu-item {
position: relative;
z-index: 1;
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
color: #FCEAC0;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 600;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.dm-head-menu-item svg {
width: 19px;
height: 19px;
flex: 0 0 auto;
color: #E7B83D;
filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.22));
}
.dm-head-menu-item:hover,
.dm-head-menu-item:focus-visible,
.dm-head-menu-item:active {
outline: none;
background: rgba(240, 184, 46, 0.09);
}
@keyframes dm-head-menu-in {
from { opacity: 0; transform: translateY(-5px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.dm-head-menu { animation: none; }
.dm-head-menu-btn { transition: none; }
}
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
.dm-head-menu--portal {
position: fixed;
right: auto;
z-index: 10000;
pointer-events: auto;
isolation: isolate;
}
/* ===== Notifications: social event cards ===== */
.notifications-screen .notification-card {
gap: 10px;
}
.notification-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.notification-avatar {
flex: 0 0 auto;
}
.notification-identity-text {
min-width: 0;
flex: 1 1 auto;
}
.notification-identity-primary {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.notification-person-name {
color: rgba(255, 255, 255, 0.96);
font-weight: 700;
overflow-wrap: anywhere;
}
.notification-login,
.notification-time-separator,
.notification-time {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
}
.notification-action,
.notification-content {
margin: 0;
}
.notification-action {
color: rgba(255, 255, 255, 0.66);
font-size: 13px;
}
.notification-content {
color: rgba(255, 255, 255, 0.94);
font-size: 15px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notification-engagement {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding-top: 2px;
color: rgba(255, 255, 255, 0.58);
}
.notification-engagement-item {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 24px;
font-size: 12px;
line-height: 1;
}
.notification-engagement-icon {
font-size: 15px;
}
.notifications-screen .notification-card--clickable {
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
}
.notifications-screen .notification-card--clickable:hover,
.notifications-screen .notification-card--clickable:focus-visible {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.055);
outline: none;
}
.notifications-screen .notification-card--clickable:active {
transform: scale(0.99);
}
+53 -22
View File
@@ -2,7 +2,7 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
background: #05070A; background: #05070A;
min-height: 100vh; min-height: 100dvh;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -22,12 +22,11 @@ body::before {
} }
.app-shell { .app-shell {
width: min(100vw, 430px); width: min(var(--app-viewport-width, 100vw), 430px);
height: 100vh; height: var(--app-viewport-height, 100vh);
height: 100svh;
position: fixed; position: fixed;
top: 0; top: var(--app-viewport-offset-top, 0px);
left: 50%; left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
transform: translateX(-50%); transform: translateX(-50%);
--call-minimized-bar-height: 0px; --call-minimized-bar-height: 0px;
--topbar-height: 0px; --topbar-height: 0px;
@@ -94,10 +93,11 @@ body::before {
.composer-slot { .composer-slot {
position: absolute; position: absolute;
left: 0; left: 50%;
right: 0; width: min(var(--app-viewport-width, 100vw), 430px);
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom)); bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px 8px; padding: 0 12px;
pointer-events: none; pointer-events: none;
} }
@@ -115,20 +115,42 @@ body::before {
transition: opacity 0.18s ease, transform 0.18s ease; transition: opacity 0.18s ease, transform 0.18s ease;
} }
body.chat-keyboard-open .screen-content { body.chat-topbar-overlay .topbar-slot {
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px))); position: fixed;
padding-bottom: calc(14px + env(safe-area-inset-bottom)); top: var(--call-minimized-bar-height, 0px);
} left: 0;
right: 0;
body.chat-keyboard-open .composer-slot { width: min(100vw, 430px);
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)); margin: 0 auto;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .toolbar-slot {
opacity: 1;
pointer-events: auto;
transform: none; transform: none;
padding: 0 12px;
}
body.chat-topbar-overlay .screen-content {
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
}
body.chat-topbar-overlay .composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
}
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
visualViewport, а область сообщений заканчивается прямо над composer. */
.app-shell.keyboard-open .toolbar-slot {
bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
bottom: var(--keyboard-offset, 0px);
}
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
} }
.connection-retry-banner { .connection-retry-banner {
@@ -177,3 +199,12 @@ body.chat-keyboard-open .toolbar-slot {
border-radius: 24px; border-radius: 24px;
} }
} }
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
padding-bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
padding-bottom: 0;
}