SHA256
Смерджить main в ветку расширения функционала каналов
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
+3
-3
@@ -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)
|
||||||
|
|||||||
@@ -57,10 +57,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
public static final short REACTION_UNLIKE = 2;
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
|
||||||
|
|
||||||
public static final short CONNECTION_CONTACT = 20;
|
public static final short CONNECTION_CONTACT = 20;
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
@@ -142,6 +140,7 @@ public final class DatabaseInitializer {
|
|||||||
}
|
}
|
||||||
if (currentVersion < SCHEMA_VERSION_11) {
|
if (currentVersion < SCHEMA_VERSION_11) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_11;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.UserNotificationEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class UserNotificationsStateDAO {
|
||||||
|
private static volatile UserNotificationsStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserNotificationsStateDAO() {}
|
||||||
|
|
||||||
|
public static UserNotificationsStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserNotificationsStateDAO.class) {
|
||||||
|
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notifications_state (
|
||||||
|
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||||
|
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||||
|
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||||
|
created_at_ms = EXCLUDED.created_at_ms,
|
||||||
|
source_login = EXCLUDED.source_login,
|
||||||
|
target_login = EXCLUDED.target_login,
|
||||||
|
target_bch_name = EXCLUDED.target_bch_name,
|
||||||
|
target_block_number = EXCLUDED.target_block_number,
|
||||||
|
target_block_hash = EXCLUDED.target_block_hash,
|
||||||
|
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||||
|
source_text = EXCLUDED.source_text
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, e.getOwnerLogin());
|
||||||
|
ps.setString(i++, e.getNotificationKind());
|
||||||
|
ps.setLong(i++, e.getCreatedAtMs());
|
||||||
|
ps.setString(i++, e.getSourceLogin());
|
||||||
|
ps.setString(i++, e.getSourceBchName());
|
||||||
|
ps.setInt(i++, e.getSourceBlockNumber());
|
||||||
|
ps.setBytes(i++, e.getSourceBlockHash());
|
||||||
|
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||||
|
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||||
|
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||||
|
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||||
|
ps.setInt(i++, e.getSourceMsgSubType());
|
||||||
|
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||||
|
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||||
|
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||||
|
FROM user_notifications_state
|
||||||
|
WHERE owner_login = ? AND notification_kind = ?
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, kind);
|
||||||
|
ps.setInt(3, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setNotificationKind(rs.getString("notification_kind"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setSourceLogin(rs.getString("source_login"));
|
||||||
|
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||||
|
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||||
|
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||||
|
e.setTargetLogin(rs.getString("target_login"));
|
||||||
|
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||||
|
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||||
|
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||||
|
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||||
|
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||||
|
e.setSourceText(rs.getString("source_text"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class UserNotificationEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String notificationKind;
|
||||||
|
private long createdAtMs;
|
||||||
|
private String sourceLogin;
|
||||||
|
private String sourceBchName;
|
||||||
|
private int sourceBlockNumber;
|
||||||
|
private byte[] sourceBlockHash;
|
||||||
|
private String targetLogin;
|
||||||
|
private String targetBchName;
|
||||||
|
private Integer targetBlockNumber;
|
||||||
|
private byte[] targetBlockHash;
|
||||||
|
private int sourceMsgSubType;
|
||||||
|
private String sourceText;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public String getNotificationKind() { return notificationKind; }
|
||||||
|
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public String getSourceLogin() { return sourceLogin; }
|
||||||
|
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||||
|
public String getSourceBchName() { return sourceBchName; }
|
||||||
|
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||||
|
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||||
|
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||||
|
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||||
|
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||||
|
public String getTargetLogin() { return targetLogin; }
|
||||||
|
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||||
|
public String getTargetBchName() { return targetBchName; }
|
||||||
|
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||||
|
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||||
|
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||||
|
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||||
|
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||||
|
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||||
|
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||||
|
public String getSourceText() { return sourceText; }
|
||||||
|
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||||
|
}
|
||||||
+4
@@ -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),
|
||||||
|
|||||||
+69
-2
@@ -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();
|
||||||
|
|||||||
+14
-2
@@ -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) {
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
|
import shine.db.entities.UserNotificationEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.UNVERIFIED,
|
||||||
|
"NOT_AUTHENTICATED",
|
||||||
|
"Операция доступна только для авторизованных пользователей"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||||
|
if (login.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||||
|
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||||
|
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||||
|
|
||||||
|
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(login);
|
||||||
|
resp.setReplies(mapRows(replyRows));
|
||||||
|
resp.setEvents(mapRows(eventRows));
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetNotifications failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||||
|
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||||
|
for (UserNotificationEntry row : rows) {
|
||||||
|
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||||
|
item.setKind(row.getNotificationKind());
|
||||||
|
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||||
|
item.setSourceLogin(row.getSourceLogin());
|
||||||
|
item.setSourceBlockchainName(row.getSourceBchName());
|
||||||
|
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||||
|
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||||
|
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||||
|
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||||
|
item.setSourceText(row.getSourceText());
|
||||||
|
item.setTargetLogin(row.getTargetLogin());
|
||||||
|
item.setTargetBlockchainName(row.getTargetBchName());
|
||||||
|
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||||
|
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||||
|
out.add(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String bytesToHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return null;
|
||||||
|
char[] HEX = "0123456789abcdef".toCharArray();
|
||||||
|
char[] out = new char[bytes.length * 2];
|
||||||
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
|
int v = bytes[i] & 0xff;
|
||||||
|
out[i * 2] = HEX[v >>> 4];
|
||||||
|
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||||
|
}
|
||||||
|
return new String(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetNotifications_Request extends Net_Request {
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetNotifications_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private List<NotificationItem> replies = new ArrayList<>();
|
||||||
|
private List<NotificationItem> events = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public List<NotificationItem> getReplies() { return replies; }
|
||||||
|
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||||
|
public List<NotificationItem> getEvents() { return events; }
|
||||||
|
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||||
|
|
||||||
|
public static class NotificationItem {
|
||||||
|
private String kind;
|
||||||
|
private long createdAtMs;
|
||||||
|
private String sourceLogin;
|
||||||
|
private String sourceBlockchainName;
|
||||||
|
private int sourceBlockNumber;
|
||||||
|
private String sourceBlockHash;
|
||||||
|
private Integer sourceMsgSubType;
|
||||||
|
private Integer connectionTypeCode;
|
||||||
|
private String sourceText;
|
||||||
|
private String targetLogin;
|
||||||
|
private String targetBlockchainName;
|
||||||
|
private Integer targetBlockNumber;
|
||||||
|
private String targetBlockHash;
|
||||||
|
|
||||||
|
public String getKind() { return kind; }
|
||||||
|
public void setKind(String kind) { this.kind = kind; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public String getSourceLogin() { return sourceLogin; }
|
||||||
|
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||||
|
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||||
|
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||||
|
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||||
|
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||||
|
public String getSourceBlockHash() { return sourceBlockHash; }
|
||||||
|
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||||
|
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
|
||||||
|
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||||
|
public Integer getConnectionTypeCode() { return connectionTypeCode; }
|
||||||
|
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
|
||||||
|
public String getSourceText() { return sourceText; }
|
||||||
|
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||||
|
public String getTargetLogin() { return targetLogin; }
|
||||||
|
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||||
|
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||||
|
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||||
|
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||||
|
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||||
|
public String getTargetBlockHash() { return targetBlockHash; }
|
||||||
|
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-логику только ради этого редкого сценария.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Build a source bundle ZIP while excluding credentials, private keys,
|
||||||
|
# local state, generated artifacts and other likely secrets.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bundle.sh
|
||||||
|
# ./bundle.sh path/to/output.zip
|
||||||
|
#
|
||||||
|
# Run from anywhere inside the project; the script resolves its own directory.
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
||||||
|
case "$OUT" in
|
||||||
|
/*) ;;
|
||||||
|
*) OUT="$ROOT/$OUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v zip >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: 'zip' is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
LIST="$TMP/files.txt"
|
||||||
|
SAFE_LIST="$TMP/safe-files.txt"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# Paths / filenames that must never be bundled.
|
||||||
|
is_denied_path() {
|
||||||
|
local p="/$1"
|
||||||
|
|
||||||
|
case "$p" in
|
||||||
|
*/.git/*|*/.git|\
|
||||||
|
*/.gradle/*|*/.gradle|\
|
||||||
|
*/.gradle-home/*|*/.gradle-home|\
|
||||||
|
*/.idea/*|*/.idea|\
|
||||||
|
*/.vscode/*|*/.vscode|\
|
||||||
|
*/node_modules/*|*/node_modules|\
|
||||||
|
*/target/*|*/target|\
|
||||||
|
*/build/*|*/build|\
|
||||||
|
*/out/*|*/out|\
|
||||||
|
*/bin/*|*/bin|\
|
||||||
|
*/logs/*|*/logs|\
|
||||||
|
*/data/*|*/data|\
|
||||||
|
*/test-ledger/*|*/test-ledger|\
|
||||||
|
*/.anchor/*|*/.anchor|\
|
||||||
|
*/.yarn/*|*/.yarn|\
|
||||||
|
*/.vendor/*|*/.vendor|\
|
||||||
|
*/.agents/*|*/.agents|\
|
||||||
|
*/.codex/*|*/.codex|\
|
||||||
|
*/.claude/*|*/.claude|\
|
||||||
|
*/deploy/backup/archive/*|\
|
||||||
|
*/scripts/*/runs/*|\
|
||||||
|
*/scripts/*/keypairs/*|\
|
||||||
|
*/keys/*|\
|
||||||
|
*/.git-local-backup/*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local base="${p##*/}"
|
||||||
|
local lower
|
||||||
|
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
.env|.env.*|\
|
||||||
|
.debug-token|\
|
||||||
|
.npmrc|.pypirc|.netrc|\
|
||||||
|
credentials|credentials.*|\
|
||||||
|
secrets|secrets.*|\
|
||||||
|
secret|secret.*|\
|
||||||
|
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||||
|
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||||
|
*keypair*.json|\
|
||||||
|
service-account*.json|\
|
||||||
|
firebase-adminsdk*.json|\
|
||||||
|
google-services.json|\
|
||||||
|
validator.log)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||||
|
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||||
|
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||||
|
.ds_store)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||||
|
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||||
|
else
|
||||||
|
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert to project-relative paths and enforce hard deny rules.
|
||||||
|
: > "$LIST"
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if [[ "$f" = /* ]]; then
|
||||||
|
rel="${f#"$ROOT"/}"
|
||||||
|
else
|
||||||
|
rel="$f"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$rel" == "$OUT" ]] && continue
|
||||||
|
[[ -z "$rel" ]] && continue
|
||||||
|
|
||||||
|
if is_denied_path "$rel"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$LIST"
|
||||||
|
done < "$TMP/files.z"
|
||||||
|
|
||||||
|
sort -u "$LIST" -o "$LIST"
|
||||||
|
|
||||||
|
# Content scan: fail closed on common credential/private-key patterns.
|
||||||
|
# We scan only text-ish files; grep -I skips binary data.
|
||||||
|
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||||
|
|
||||||
|
: > "$SAFE_LIST"
|
||||||
|
found_secret=0
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[[ -f "$ROOT/$rel" ]] || continue
|
||||||
|
|
||||||
|
# Files that contain examples/templates can legitimately mention secret keys
|
||||||
|
# with placeholders. They are scanned too, but placeholder-looking values
|
||||||
|
# are less likely to match the regex above.
|
||||||
|
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||||
|
echo "BLOCKED: possible secret in $rel" >&2
|
||||||
|
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||||
|
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||||
|
| head -n 3 >&2 || true
|
||||||
|
found_secret=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||||
|
done < "$LIST"
|
||||||
|
|
||||||
|
if (( found_secret != 0 )); then
|
||||||
|
echo >&2
|
||||||
|
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||||
|
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||||
|
echo "ERROR: no files left to bundle." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f -- "$OUT"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT"
|
||||||
|
zip -q -9 "$OUT" -@ < "$SAFE_LIST"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Created: $OUT"
|
||||||
|
echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')"
|
||||||
|
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||||
@@ -58,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 |
|
||||||
|
|||||||
@@ -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
@@ -12,13 +12,13 @@
|
|||||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||||
<title>СИЯНИЕ</title>
|
<title>СИЯНИЕ</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260819190000';
|
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||||
cssFiles.forEach((file) => {
|
cssFiles.forEach((file) => {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
|
|||||||
+29
-6
@@ -11,6 +11,7 @@ import {
|
|||||||
setClientErrorSentNotifier,
|
setClientErrorSentNotifier,
|
||||||
setClientErrorTransport,
|
setClientErrorTransport,
|
||||||
} from './services/client-error-reporter.js';
|
} from './services/client-error-reporter.js';
|
||||||
|
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
|
||||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||||
import { initPwaPush } from './services/pwa-push-service.js';
|
import { initPwaPush } from './services/pwa-push-service.js';
|
||||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||||
@@ -85,17 +86,17 @@ import * as appLogView from './pages/app-log-view.js';
|
|||||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||||
import * as messagesList from './pages/messages-list.js';
|
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||||
import * as contactSearchView from './pages/contact-search-view.js';
|
import * as contactSearchView from './pages/contact-search-view.js';
|
||||||
import * as chatView from './pages/chat-view.js?v=202608191738';
|
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||||
import * as userProfileView from './pages/user-profile-view.js';
|
import * as userProfileView from './pages/user-profile-view.js';
|
||||||
import * as channelsList from './pages/channels-list.js';
|
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||||
import * as channelView from './pages/channel-view.js';
|
import * as channelView from './pages/channel-view.js';
|
||||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||||
import * as networkView from './pages/network-view.js';
|
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||||
import * as notificationsView from './pages/notifications-view.js';
|
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||||
|
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||||
@@ -195,6 +196,15 @@ let initialConnectionCompleted = false;
|
|||||||
let orientationLockInFlight = false;
|
let orientationLockInFlight = false;
|
||||||
let currentChromeCleanup = null;
|
let currentChromeCleanup = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
|
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||||
|
'messages-list',
|
||||||
|
'chat-view',
|
||||||
|
'channels-list',
|
||||||
|
'channel-view',
|
||||||
|
'channel-thread-view',
|
||||||
|
'notifications-view',
|
||||||
|
]);
|
||||||
|
|
||||||
const GUEST_ALLOWED_PAGES = new Set([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
@@ -1137,6 +1147,14 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
refreshConnectionUi();
|
refreshConnectionUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachPageScrollToBottom(pageId, screen) {
|
||||||
|
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
|
||||||
|
|
||||||
|
return attachScrollToBottomButton({
|
||||||
|
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
@@ -1173,7 +1191,12 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
screenEl.append(screen);
|
screenEl.append(screen);
|
||||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||||
|
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
||||||
|
currentCleanup = () => {
|
||||||
|
pageCleanup?.();
|
||||||
|
scrollToBottomControl?.cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||||
|
|||||||
@@ -25,27 +25,28 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
|||||||
return (cleanLogin[0] || '?').toUpperCase();
|
return (cleanLogin[0] || '?').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderUserAvatar({
|
export function renderAvatar({
|
||||||
login,
|
initials = '?',
|
||||||
firstName = '',
|
|
||||||
lastName = '',
|
|
||||||
avatar = null,
|
avatar = null,
|
||||||
size = 'large',
|
size = 'large',
|
||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
|
alt = 'Аватар',
|
||||||
|
glow = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
const classes = ['avatar', 'avatar-image'];
|
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||||
const sizeClass = pickSizeClass(size);
|
const sizeClass = pickSizeClass(size);
|
||||||
if (sizeClass) classes.push(sizeClass);
|
if (sizeClass) classes.add(sizeClass);
|
||||||
const extraClass = String(className || '').trim();
|
const extraClass = String(className || '').trim();
|
||||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
||||||
wrap.className = classes.join(' ');
|
if (glow) classes.add('avatar-glow');
|
||||||
|
wrap.className = Array.from(classes).join(' ');
|
||||||
if (title) wrap.title = String(title);
|
if (title) wrap.title = String(title);
|
||||||
|
|
||||||
const fallback = document.createElement('span');
|
const fallback = document.createElement('span');
|
||||||
fallback.className = 'avatar-fallback';
|
fallback.className = 'avatar-fallback';
|
||||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||||
wrap.append(fallback);
|
wrap.append(fallback);
|
||||||
|
|
||||||
const txId = String(avatar?.ar || '').trim();
|
const txId = String(avatar?.ar || '').trim();
|
||||||
@@ -56,7 +57,8 @@ export function renderUserAvatar({
|
|||||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = 'Аватар';
|
img.className = 'avatar-photo';
|
||||||
|
img.alt = String(alt || 'Аватар');
|
||||||
img.loading = 'lazy';
|
img.loading = 'lazy';
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
wrap.append(img);
|
wrap.append(img);
|
||||||
@@ -131,3 +133,24 @@ export function renderUserAvatar({
|
|||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderUserAvatar({
|
||||||
|
login,
|
||||||
|
firstName = '',
|
||||||
|
lastName = '',
|
||||||
|
avatar = null,
|
||||||
|
size = 'large',
|
||||||
|
className = '',
|
||||||
|
title = '',
|
||||||
|
glow = false,
|
||||||
|
} = {}) {
|
||||||
|
return renderAvatar({
|
||||||
|
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||||
|
avatar,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
alt: 'Аватар',
|
||||||
|
glow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||||
const wrap = document.createElement('header');
|
const wrap = document.createElement('header');
|
||||||
wrap.className = 'page-header';
|
wrap.className = 'page-header app-topbar-shell';
|
||||||
|
|
||||||
const left = document.createElement('div');
|
const left = document.createElement('div');
|
||||||
left.className = 'header-left';
|
left.className = 'header-left';
|
||||||
if (leftAction) {
|
if (leftAction) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'icon-btn';
|
const rawLabel = String(leftAction.label || '').trim();
|
||||||
btn.textContent = leftAction.label;
|
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||||
|
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||||
|
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||||
|
if (isBackAction) {
|
||||||
|
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||||
|
btn.title = leftAction.title || 'Назад';
|
||||||
|
}
|
||||||
btn.addEventListener('click', leftAction.onClick);
|
btn.addEventListener('click', leftAction.onClick);
|
||||||
left.append(btn);
|
left.append(btn);
|
||||||
}
|
}
|
||||||
@@ -19,9 +25,16 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
|||||||
left.append(label);
|
left.append(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
const h1 = document.createElement('h1');
|
const center = document.createElement('div');
|
||||||
h1.className = 'page-title';
|
center.className = 'header-center';
|
||||||
h1.textContent = title;
|
if (centerNode instanceof Node) {
|
||||||
|
center.append(centerNode);
|
||||||
|
} else {
|
||||||
|
const h1 = document.createElement('h1');
|
||||||
|
h1.className = 'page-title';
|
||||||
|
h1.textContent = title;
|
||||||
|
center.append(h1);
|
||||||
|
}
|
||||||
|
|
||||||
const right = document.createElement('div');
|
const right = document.createElement('div');
|
||||||
right.className = 'header-actions';
|
right.className = 'header-actions';
|
||||||
@@ -40,6 +53,6 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
|||||||
right.append(btn);
|
right.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.append(left, h1, right);
|
wrap.append(left, center, right);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function createOverflowDots({ className = '' } = {}) {
|
||||||
|
const dots = document.createElement('span');
|
||||||
|
const extra = String(className || '').trim();
|
||||||
|
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
||||||
|
dots.setAttribute('aria-hidden', 'true');
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
dots.append(document.createElement('i'));
|
||||||
|
}
|
||||||
|
return dots;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
function resolveElement(value) {
|
||||||
|
return typeof value === 'function' ? value() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArrowIcon() {
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'scroll-to-bottom-btn__icon';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
icon.textContent = '↓';
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachScrollToBottomButton({
|
||||||
|
scrollContainer,
|
||||||
|
mountTarget = document.querySelector('.app-shell'),
|
||||||
|
thresholdPx = 160,
|
||||||
|
title = 'Вниз',
|
||||||
|
} = {}) {
|
||||||
|
const target = resolveElement(mountTarget);
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return {
|
||||||
|
button: null,
|
||||||
|
refresh() {},
|
||||||
|
cleanup() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'scroll-to-bottom-btn';
|
||||||
|
button.title = title;
|
||||||
|
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||||
|
button.tabIndex = -1;
|
||||||
|
button.append(buildArrowIcon());
|
||||||
|
target.append(button);
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let boundContainer = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let mutationObserver = null;
|
||||||
|
let refreshFrame = 0;
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
button.classList.remove('is-visible');
|
||||||
|
button.setAttribute('aria-hidden', 'true');
|
||||||
|
button.tabIndex = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
if (disposed || refreshFrame) return;
|
||||||
|
refreshFrame = window.requestAnimationFrame(() => {
|
||||||
|
refreshFrame = 0;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindContainer = () => {
|
||||||
|
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
mutationObserver = null;
|
||||||
|
boundContainer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindContainer = () => {
|
||||||
|
const nextContainer = resolveElement(scrollContainer);
|
||||||
|
if (!(nextContainer instanceof Element)) {
|
||||||
|
if (boundContainer) unbindContainer();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (nextContainer === boundContainer) return boundContainer;
|
||||||
|
|
||||||
|
unbindContainer();
|
||||||
|
boundContainer = nextContainer;
|
||||||
|
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
resizeObserver = new ResizeObserver(scheduleRefresh);
|
||||||
|
resizeObserver.observe(boundContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof MutationObserver === 'function') {
|
||||||
|
mutationObserver = new MutationObserver(scheduleRefresh);
|
||||||
|
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundContainer;
|
||||||
|
};
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (disposed) return;
|
||||||
|
const container = bindContainer();
|
||||||
|
if (!container) {
|
||||||
|
hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollHeight = Number(container.scrollHeight || 0);
|
||||||
|
const clientHeight = Number(container.clientHeight || 0);
|
||||||
|
const scrollTop = Number(container.scrollTop || 0);
|
||||||
|
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||||
|
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
||||||
|
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
||||||
|
|
||||||
|
button.classList.toggle('is-visible', shouldShow);
|
||||||
|
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||||
|
button.tabIndex = shouldShow ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
const container = bindContainer();
|
||||||
|
if (!container) return;
|
||||||
|
if (typeof container.scrollTo === 'function') {
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
scheduleRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
button.addEventListener('click', scrollToBottom);
|
||||||
|
window.addEventListener('resize', scheduleRefresh);
|
||||||
|
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
||||||
|
window.requestAnimationFrame(refresh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
button,
|
||||||
|
refresh: scheduleRefresh,
|
||||||
|
cleanup() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
if (refreshFrame) {
|
||||||
|
window.cancelAnimationFrame(refreshFrame);
|
||||||
|
refreshFrame = 0;
|
||||||
|
}
|
||||||
|
unbindContainer();
|
||||||
|
window.removeEventListener('resize', scheduleRefresh);
|
||||||
|
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
||||||
|
button.removeEventListener('click', scrollToBottom);
|
||||||
|
button.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
openArweaveAttachmentManager,
|
openArweaveAttachmentManager,
|
||||||
markArweaveAttachmentPlaced,
|
markArweaveAttachmentPlaced,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||||
@@ -40,17 +40,16 @@ function normalizeMetaText(value, max, label) {
|
|||||||
function renderAvatarPreview(slot, avatar, title) {
|
function renderAvatarPreview(slot, avatar, title) {
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
const wrap = document.createElement('div');
|
const label = String(title || 'К').trim() || 'К';
|
||||||
wrap.className = 'channel-profile-avatar';
|
const wrap = renderAvatar({
|
||||||
|
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||||
|
avatar,
|
||||||
|
size: 'small',
|
||||||
|
className: 'channel-profile-avatar',
|
||||||
|
title: label,
|
||||||
|
alt: 'Аватар канала',
|
||||||
|
});
|
||||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||||
if (avatar?.ar) {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.alt = 'Аватар канала';
|
|
||||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
|
||||||
wrap.append(img);
|
|
||||||
} else {
|
|
||||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
|
||||||
}
|
|
||||||
slot.append(wrap);
|
slot.append(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
readArweaveAttachmentHistory,
|
readArweaveAttachmentHistory,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { formatBytes } from '../services/attachment-format.js';
|
import { formatBytes } from '../services/attachment-format.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
|
||||||
@@ -73,13 +74,14 @@ export function render({ navigate }) {
|
|||||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню">⋮</button>
|
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||||
|
|
||||||
const statusLine = document.createElement('p');
|
const statusLine = document.createElement('p');
|
||||||
statusLine.className = 'meta-muted inline-error';
|
statusLine.className = 'meta-muted inline-error';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||||
import { captureClientError } from '../services/client-error-reporter.js';
|
import { captureClientError } from '../services/client-error-reporter.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
@@ -1144,31 +1144,26 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
|
||||||
|
const threadHeaderButton = document.createElement('button');
|
||||||
|
threadHeaderButton.type = 'button';
|
||||||
|
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||||
|
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||||
|
threadHeaderButton.disabled = true;
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
centerNode: threadHeaderButton,
|
||||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||||
rightActions: [],
|
rightActions: [
|
||||||
|
{
|
||||||
|
label: '↑',
|
||||||
|
title: 'К списку каналов',
|
||||||
|
ariaLabel: 'К списку каналов',
|
||||||
|
className: 'channel-thread-list-btn',
|
||||||
|
onClick: () => navigate('channels-list'),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
header.classList.add('channel-thread-topbar');
|
header.classList.add('channel-thread-topbar');
|
||||||
const headerLeft = header.querySelector('.header-left');
|
|
||||||
let threadHeaderButton = null;
|
|
||||||
if (headerLeft) {
|
|
||||||
const channelsListButton = document.createElement('button');
|
|
||||||
channelsListButton.type = 'button';
|
|
||||||
channelsListButton.className = 'icon-btn';
|
|
||||||
channelsListButton.textContent = '↑';
|
|
||||||
channelsListButton.title = 'К списку каналов';
|
|
||||||
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
|
||||||
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
|
||||||
headerLeft.append(channelsListButton);
|
|
||||||
|
|
||||||
threadHeaderButton = document.createElement('button');
|
|
||||||
threadHeaderButton.type = 'button';
|
|
||||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
|
||||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
|
||||||
threadHeaderButton.disabled = true;
|
|
||||||
headerLeft.append(threadHeaderButton);
|
|
||||||
}
|
|
||||||
chrome?.setTopbar(header);
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
@@ -1180,6 +1175,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const current = document.querySelector('section.channels-screen--thread');
|
const current = document.querySelector('section.channels-screen--thread');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
|
current.cleanup?.();
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||||
@@ -1352,6 +1348,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
invalid.className = 'card meta-muted';
|
invalid.className = 'card meta-muted';
|
||||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||||
screen.append(invalid);
|
screen.append(invalid);
|
||||||
|
screen.cleanup = () => {
|
||||||
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
getMessageReactionState,
|
getMessageReactionState,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
composeMessageWithAttachments,
|
composeMessageWithAttachments,
|
||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
@@ -578,18 +577,17 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChannelAvatarElement(channel, size = 72) {
|
function createChannelAvatarElement(channel, size = 72) {
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'channel-profile-avatar';
|
|
||||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
|
||||||
const txId = String(channel?.avaAr || '').trim();
|
const txId = String(channel?.avaAr || '').trim();
|
||||||
if (txId) {
|
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||||
const img = document.createElement('img');
|
const wrap = renderAvatar({
|
||||||
img.alt = 'Аватар канала';
|
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
avatar: txId ? { ar: txId } : null,
|
||||||
wrap.append(img);
|
size: 'small',
|
||||||
} else {
|
className: 'channel-profile-avatar',
|
||||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
title,
|
||||||
}
|
alt: 'Аватар канала',
|
||||||
|
});
|
||||||
|
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2271,19 +2269,20 @@ export function render({ navigate, route, chrome }) {
|
|||||||
statusBox.style.display = '';
|
statusBox.style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const channelHeaderButton = document.createElement('button');
|
||||||
|
channelHeaderButton.type = 'button';
|
||||||
|
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||||
|
channelHeaderButton.textContent = 'Канал: ...';
|
||||||
|
channelHeaderButton.disabled = true;
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
centerNode: channelHeaderButton,
|
||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
|
||||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
if (channelHeaderButton) {
|
|
||||||
channelHeaderButton.disabled = true;
|
|
||||||
}
|
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2294,6 +2293,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const current = document.querySelector('section.channels-screen--channel');
|
const current = document.querySelector('section.channels-screen--channel');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
|
current.cleanup?.();
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
};
|
};
|
||||||
let activeSelector = null;
|
let activeSelector = null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { channels as mockChannels } from '../mock-data.js';
|
import { channels as mockChannels } from '../mock-data.js';
|
||||||
import { authService, setChannelsFeed, state } from '../state.js';
|
import { authService, setChannelsFeed, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
@@ -90,6 +90,16 @@ function avatarLetterFromName(name = '') {
|
|||||||
return first.toUpperCase();
|
return first.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createChannelAvatar(channel = {}) {
|
||||||
|
return renderAvatar({
|
||||||
|
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||||
|
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||||
|
size: 'small',
|
||||||
|
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||||
|
alt: 'Аватар канала',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function allFeedSummaries() {
|
function allFeedSummaries() {
|
||||||
const feed = state.channelsFeed || {};
|
const feed = state.channelsFeed || {};
|
||||||
return [
|
return [
|
||||||
@@ -885,7 +895,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'channel-row';
|
row.className = 'channel-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
|
||||||
<div class="channel-row-main">
|
<div class="channel-row-main">
|
||||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||||
@@ -894,6 +903,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
<span class="channel-row-time">—</span>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
row.prepend(createChannelAvatar(channel));
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
const route = channel.route || makeShineChannelRoute({
|
const route = channel.route || makeShineChannelRoute({
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
ownerLogin: String(channel.ownerName || 'channel'),
|
||||||
@@ -939,7 +949,6 @@ function openTopChannelsMenu({
|
|||||||
listState,
|
listState,
|
||||||
anchorEl,
|
anchorEl,
|
||||||
navigate,
|
navigate,
|
||||||
onSubscribeChannel,
|
|
||||||
onFindChannel,
|
onFindChannel,
|
||||||
}) {
|
}) {
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
@@ -951,7 +960,7 @@ function openTopChannelsMenu({
|
|||||||
let left = rect.right - menuWidth;
|
let left = rect.right - menuWidth;
|
||||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||||
|
|
||||||
const estimatedHeight = 320;
|
const estimatedHeight = 250;
|
||||||
let top = rect.bottom + 8;
|
let top = rect.bottom + 8;
|
||||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||||
@@ -968,14 +977,12 @@ function openTopChannelsMenu({
|
|||||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ label: 'Поиск', action: () => onFindChannel?.() },
|
||||||
|
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||||
|
{ divider: true },
|
||||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
|
||||||
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -1206,16 +1213,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||||
row.classList.toggle('is-counters-visible', countersVisible);
|
row.classList.toggle('is-counters-visible', countersVisible);
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = createChannelAvatar(channel);
|
||||||
avatar.className = 'avatar';
|
|
||||||
if (channel.avaAr) {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.alt = '';
|
|
||||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
|
||||||
avatar.append(img);
|
|
||||||
} else {
|
|
||||||
avatar.textContent = channel.avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = renderChannelMain(channel);
|
const main = renderChannelMain(channel);
|
||||||
|
|
||||||
@@ -1229,11 +1227,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);
|
||||||
if (unreadCount > 0) {
|
count.hidden = unreadCount <= 0;
|
||||||
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
controls.append(count);
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
}
|
|
||||||
controls.append(time);
|
controls.append(time, count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -1250,6 +1248,14 @@ 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);
|
||||||
@@ -1337,43 +1343,18 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
const backBtn = document.createElement('button');
|
|
||||||
backBtn.type = 'button';
|
|
||||||
backBtn.className = 'icon-btn channels-top-back-btn';
|
|
||||||
backBtn.textContent = '←';
|
|
||||||
backBtn.setAttribute('aria-label', 'Назад');
|
|
||||||
backBtn.addEventListener('click', () => navigateBack());
|
|
||||||
|
|
||||||
const topTitle = document.createElement('strong');
|
const topTitle = document.createElement('strong');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.className = 'channels-top-title';
|
||||||
|
|
||||||
const topBarRight = document.createElement('div');
|
const topBarRight = document.createElement('div');
|
||||||
topBarRight.className = 'channels-top-right';
|
topBarRight.className = 'channels-top-right';
|
||||||
|
|
||||||
const findChannelBtn = document.createElement('button');
|
|
||||||
findChannelBtn.type = 'button';
|
|
||||||
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
|
||||||
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
|
||||||
findChannelBtn.title = 'Найти канал';
|
|
||||||
const findChannelIcon = document.createElement('span');
|
|
||||||
findChannelIcon.className = 'channels-search-icon';
|
|
||||||
findChannelIcon.setAttribute('aria-hidden', 'true');
|
|
||||||
findChannelBtn.append(findChannelIcon);
|
|
||||||
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
|
||||||
|
|
||||||
const createInMyBtn = document.createElement('button');
|
|
||||||
createInMyBtn.type = 'button';
|
|
||||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
|
||||||
createInMyBtn.textContent = '+';
|
|
||||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
|
||||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
|
||||||
|
|
||||||
const topMenuBtn = document.createElement('button');
|
const topMenuBtn = document.createElement('button');
|
||||||
topMenuBtn.type = 'button';
|
topMenuBtn.type = 'button';
|
||||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||||
topMenuBtn.title = 'Ещё действия';
|
topMenuBtn.title = 'Ещё действия';
|
||||||
topMenuBtn.textContent = '⋮';
|
topMenuBtn.append(createOverflowDots());
|
||||||
topMenuBtn.addEventListener('click', (event) => {
|
topMenuBtn.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(topMenuBtn);
|
animatePress(topMenuBtn);
|
||||||
@@ -1382,18 +1363,14 @@ export function render({ navigate, route, chrome }) {
|
|||||||
anchorEl: topMenuBtn,
|
anchorEl: topMenuBtn,
|
||||||
navigate,
|
navigate,
|
||||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||||
onSubscribeChannel: () => openSimpleSubscribeModal({
|
|
||||||
kind: 'channel',
|
|
||||||
kindLabel: 'Добавить канал',
|
|
||||||
submitLabel: 'Добавить',
|
|
||||||
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
topBarLeft.append(backBtn, topTitle);
|
topBarRight.append(topMenuBtn);
|
||||||
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
|
||||||
|
const bottomCta = document.createElement('button');
|
||||||
|
bottomCta.type = 'button';
|
||||||
|
|
||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
@@ -1411,19 +1388,21 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
findChannelBtn.style.display = '';
|
|
||||||
createInMyBtn.style.display = '';
|
|
||||||
topMenuBtn.style.display = '';
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||||
|
|
||||||
|
updateBottomCta({ button: bottomCta });
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome?.setTopbar(topBarEl);
|
chrome?.setTopbar(topBarEl);
|
||||||
screen.append(contentEl);
|
screen.append(contentEl, bottomCta);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBottomCta({ button: bottomCta });
|
||||||
|
|
||||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
rerenderList();
|
rerenderList();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -27,10 +29,55 @@ import {
|
|||||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
|
|
||||||
|
function createChatHeaderParts(login) {
|
||||||
|
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||||
|
|
||||||
|
const avatarSlot = document.createElement('span');
|
||||||
|
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||||
|
const initialAvatar = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
size: 'small',
|
||||||
|
className: 'chat-header-avatar',
|
||||||
|
title: cleanLogin,
|
||||||
|
});
|
||||||
|
avatarSlot.append(initialAvatar);
|
||||||
|
|
||||||
|
const loginEl = document.createElement('span');
|
||||||
|
loginEl.className = 'chat-header-login';
|
||||||
|
loginEl.setAttribute('role', 'heading');
|
||||||
|
loginEl.setAttribute('aria-level', '1');
|
||||||
|
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||||
|
loginEl.textContent = cleanLogin;
|
||||||
|
|
||||||
|
void loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
if (!avatarSlot.isConnected) return;
|
||||||
|
const upgradedAvatar = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
firstName: String(snapshot?.firstName || '').trim(),
|
||||||
|
lastName: String(snapshot?.lastName || '').trim(),
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'small',
|
||||||
|
className: 'chat-header-avatar',
|
||||||
|
title: cleanLogin,
|
||||||
|
});
|
||||||
|
avatarSlot.replaceChildren(upgradedAvatar);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return { centerNode: loginEl, avatarSlot };
|
||||||
|
}
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@@ -906,7 +953,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||||
|
|
||||||
const historyLoader = document.createElement('div');
|
const historyLoader = document.createElement('div');
|
||||||
historyLoader.className = 'dm-history-loader';
|
historyLoader.className = 'dm-history-loader';
|
||||||
historyLoader.hidden = true;
|
historyLoader.hidden = true;
|
||||||
@@ -920,9 +966,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const log = document.createElement('div');
|
const log = document.createElement('div');
|
||||||
log.className = 'messages-log dm-messages-log';
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
chrome?.setTopbar(
|
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||||
renderHeader({
|
const chatHeader = renderHeader({
|
||||||
title: `Чат с ${contact.name}`,
|
centerNode: chatHeaderParts.centerNode,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{
|
{
|
||||||
@@ -933,7 +979,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
onClick: () => handleStartCall('audio'),
|
onClick: () => handleStartCall('audio'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '⋮',
|
iconNode: createOverflowDots(),
|
||||||
title: 'Действия чата',
|
title: 'Действия чата',
|
||||||
ariaLabel: 'Открыть меню действий чата',
|
ariaLabel: 'Открыть меню действий чата',
|
||||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||||
@@ -990,8 +1036,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
});
|
||||||
);
|
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||||
|
chrome?.setTopbar(chatHeader);
|
||||||
|
|
||||||
if (!isKnownContact) {
|
if (!isKnownContact) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
@@ -44,13 +45,14 @@ async function loadDmAvatarSnapshot(login) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDmAvatar(login) {
|
function createDmAvatar(login, { className = '' } = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
|
className,
|
||||||
});
|
});
|
||||||
if (!cleanLogin) return avatarEl;
|
if (!cleanLogin) return avatarEl;
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
@@ -65,6 +67,7 @@ function createDmAvatar(login) {
|
|||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
|
className,
|
||||||
});
|
});
|
||||||
upgraded.classList.add('avatar');
|
upgraded.classList.add('avatar');
|
||||||
avatarEl.replaceWith(upgraded);
|
avatarEl.replaceWith(upgraded);
|
||||||
@@ -160,13 +163,9 @@ function compareChatRows(a, b) {
|
|||||||
return nameA.localeCompare(nameB, 'ru');
|
return nameA.localeCompare(nameB, 'ru');
|
||||||
}
|
}
|
||||||
|
|
||||||
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
|
||||||
|
|
||||||
export function render({ navigate, chrome }) {
|
export function render({ navigate, chrome }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
const login = String(state.session.login || '').trim();
|
|
||||||
|
|
||||||
const head = document.createElement('header');
|
const head = document.createElement('header');
|
||||||
head.className = 'dm-head';
|
head.className = 'dm-head';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
@@ -178,9 +177,7 @@ export function render({ navigate, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="dm-head-title">Чаты</h1>
|
<h1 class="dm-head-title">Чаты</h1>
|
||||||
<div class="dm-head-menu-wrap">
|
<div class="dm-head-menu-wrap">
|
||||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false">
|
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||||
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
||||||
</button>
|
|
||||||
<div class="dm-head-menu" role="menu" hidden>
|
<div class="dm-head-menu" role="menu" hidden>
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
@@ -192,11 +189,10 @@ export function render({ navigate, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const headName = head.querySelector('.dm-head-name');
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
if (headName) headName.textContent = login;
|
menuButton?.append(createOverflowDots());
|
||||||
|
|
||||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
|
||||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
let persistedCenterLogin = '';
|
let persistedCenterLogin = '';
|
||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route, chrome } = {}) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
@@ -282,10 +282,7 @@ export function render({ navigate, route }) {
|
|||||||
else window.history.replaceState({}, '', nextPath);
|
else window.history.replaceState({}, '', nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackButtonState(backBtn) {
|
|
||||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
|
||||||
backBtn.disabled = centerHistory.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSearchModal() {
|
function openSearchModal() {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@@ -490,7 +487,6 @@ export function render({ navigate, route }) {
|
|||||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
|
||||||
persistHistory();
|
persistHistory();
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== loadSeq) return;
|
if (requestId !== loadSeq) return;
|
||||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||||
@@ -499,24 +495,13 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
leftAction: {
|
|
||||||
label: '←',
|
|
||||||
onClick: () => {
|
|
||||||
if (!centerHistory.length) return;
|
|
||||||
const prev = centerHistory.pop();
|
|
||||||
if (!prev) {
|
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void load(prev, { pushHistory: false });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Найти', onClick: openSearchModal },
|
{ label: 'Найти', onClick: openSearchModal },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
||||||
setBackButtonState(backBtnEl);
|
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
||||||
|
header.classList.add('network-topbar');
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
@@ -542,11 +527,10 @@ export function render({ navigate, route }) {
|
|||||||
window.setTimeout(() => openSearchModal(), 0);
|
window.setTimeout(() => openSearchModal(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBackButtonState(backBtnEl);
|
|
||||||
|
|
||||||
// Панель фильтров слоёв (оверлей под шапкой)
|
// Панель фильтров слоёв (оверлей под шапкой)
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar';
|
filterBar.className = 'fg-filter-bar app-top-tabs';
|
||||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
@@ -560,8 +544,8 @@ export function render({ navigate, route }) {
|
|||||||
filterBar.append(chip);
|
filterBar.append(chip);
|
||||||
});
|
});
|
||||||
|
|
||||||
header.classList.add('network-header-overlay');
|
chrome?.setTopbar(header);
|
||||||
stage.append(board, header, filterBar);
|
stage.append(board, filterBar);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,394 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { authService, state } from '../state.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
|
const CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
const profileSnapshotCache = new Map();
|
||||||
|
const profileSnapshotPending = new Map();
|
||||||
|
|
||||||
|
function connectionTypeLabel(typeCode) {
|
||||||
|
switch (Number(typeCode)) {
|
||||||
|
case CONNECTION_CLOSE_FRIEND:
|
||||||
|
return 'близкие друзья';
|
||||||
|
default:
|
||||||
|
return 'новую связь';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||||
|
|
||||||
function renderList(container) {
|
function normalizeItem(item) {
|
||||||
const active = state.notificationsTab;
|
return {
|
||||||
container.innerHTML = '';
|
kind: String(item?.kind || ''),
|
||||||
const card = document.createElement('article');
|
createdAtMs: Number(item?.createdAtMs || 0),
|
||||||
card.className = 'card stack';
|
sourceLogin: String(item?.sourceLogin || ''),
|
||||||
|
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
||||||
const title = document.createElement('strong');
|
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
||||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
||||||
|
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
||||||
const description = document.createElement('p');
|
sourceText: String(item?.sourceText || ''),
|
||||||
description.className = 'meta-muted';
|
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
||||||
description.textContent = active === 'events'
|
targetLogin: String(item?.targetLogin || ''),
|
||||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
||||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
||||||
|
targetBlockHash: String(item?.targetBlockHash || ''),
|
||||||
const note = document.createElement('p');
|
profile: null,
|
||||||
note.className = 'meta-muted';
|
engagement: null,
|
||||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
};
|
||||||
|
|
||||||
card.append(title, description, note);
|
|
||||||
container.append(card);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ chrome } = {}) {
|
function formatRelativeTime(value) {
|
||||||
|
const ts = Number(value || 0);
|
||||||
|
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||||
|
|
||||||
|
const diffMs = Math.max(0, Date.now() - ts);
|
||||||
|
const minute = 60 * 1000;
|
||||||
|
const hour = 60 * minute;
|
||||||
|
const day = 24 * hour;
|
||||||
|
const week = 7 * day;
|
||||||
|
|
||||||
|
if (diffMs < minute) return 'сейчас';
|
||||||
|
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
||||||
|
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
||||||
|
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
||||||
|
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileField(snapshot, key) {
|
||||||
|
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
||||||
|
.find((field) => String(field?.key || '') === key);
|
||||||
|
return String(row?.value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCachedProfileSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
|
||||||
|
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
|
||||||
|
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
profileSnapshotCache.set(key, snapshot || null);
|
||||||
|
profileSnapshotPending.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
profileSnapshotCache.set(key, null);
|
||||||
|
profileSnapshotPending.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
profileSnapshotPending.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEngagement(source) {
|
||||||
|
if (!source || typeof source !== 'object') return null;
|
||||||
|
const likesCount = Math.max(0, Number(source.likesCount || 0));
|
||||||
|
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
|
||||||
|
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
|
||||||
|
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
|
||||||
|
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
|
||||||
|
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
|
||||||
|
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
|
||||||
|
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
|
||||||
|
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.values(result).some((count) => count > 0) ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceEngagement(item) {
|
||||||
|
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||||
|
const blockNumber = Number(item?.sourceBlockNumber);
|
||||||
|
const blockHash = String(item?.sourceBlockHash || '').trim();
|
||||||
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await authService.getMessageThread(
|
||||||
|
{ blockchainName, blockNumber, blockHash },
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
String(state.session.login || '').trim(),
|
||||||
|
);
|
||||||
|
return normalizeEngagement(payload?.focus);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichItem(item, activeTab) {
|
||||||
|
const [profile, engagement] = await Promise.all([
|
||||||
|
loadCachedProfileSnapshot(item.sourceLogin),
|
||||||
|
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
return { ...item, profile, engagement };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(activeTab) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'card stack notification-empty-state';
|
||||||
|
const title = document.createElement('strong');
|
||||||
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'meta-muted';
|
||||||
|
text.textContent = activeTab === 'events'
|
||||||
|
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||||
|
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||||
|
card.append(title, text);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIdentity(item) {
|
||||||
|
const profile = item.profile;
|
||||||
|
const firstName = profileField(profile, 'first_name');
|
||||||
|
const lastName = profileField(profile, 'last_name');
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
||||||
|
const avatar = profile?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(profile.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'notification-identity';
|
||||||
|
header.append(renderUserAvatar({
|
||||||
|
login: item.sourceLogin || 'unknown',
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
avatar,
|
||||||
|
size: 'small',
|
||||||
|
className: 'notification-avatar',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'notification-identity-text';
|
||||||
|
|
||||||
|
const primary = document.createElement('div');
|
||||||
|
primary.className = 'notification-identity-primary';
|
||||||
|
const name = document.createElement('strong');
|
||||||
|
name.className = 'notification-person-name';
|
||||||
|
name.textContent = fullName;
|
||||||
|
primary.append(name);
|
||||||
|
|
||||||
|
const login = String(item.sourceLogin || '').trim();
|
||||||
|
if (login) {
|
||||||
|
const loginEl = document.createElement('span');
|
||||||
|
loginEl.className = 'notification-login';
|
||||||
|
loginEl.textContent = `@${login}`;
|
||||||
|
primary.append(loginEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = formatRelativeTime(item.createdAtMs);
|
||||||
|
if (relative) {
|
||||||
|
const separator = document.createElement('span');
|
||||||
|
separator.className = 'notification-time-separator';
|
||||||
|
separator.textContent = '·';
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'notification-time';
|
||||||
|
time.textContent = relative;
|
||||||
|
primary.append(separator, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
text.append(primary);
|
||||||
|
header.append(text);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEngagement(engagement) {
|
||||||
|
if (!engagement) return null;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||||
|
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||||
|
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||||
|
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||||
|
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||||
|
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||||
|
|
||||||
|
if (!stats.length) return null;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'notification-engagement';
|
||||||
|
stats.forEach(({ key, icon, label }) => {
|
||||||
|
const stat = document.createElement('span');
|
||||||
|
stat.className = 'notification-engagement-item';
|
||||||
|
stat.title = label;
|
||||||
|
|
||||||
|
const iconEl = document.createElement('span');
|
||||||
|
iconEl.className = 'notification-engagement-icon';
|
||||||
|
iconEl.setAttribute('aria-hidden', 'true');
|
||||||
|
iconEl.textContent = icon;
|
||||||
|
|
||||||
|
const countEl = document.createElement('span');
|
||||||
|
countEl.className = 'notification-engagement-count';
|
||||||
|
countEl.textContent = String(engagement[key]);
|
||||||
|
stat.append(iconEl, countEl);
|
||||||
|
row.append(stat);
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationRoute(item, activeTab) {
|
||||||
|
if (activeTab === 'events') {
|
||||||
|
const login = String(item?.sourceLogin || '').trim();
|
||||||
|
return login ? makeProfileRoute(login) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
||||||
|
const blockNumber = Number(item?.sourceBlockNumber);
|
||||||
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
|
||||||
|
|
||||||
|
return makeShineMessageRoute({
|
||||||
|
messageBlockchainName: blockchainName,
|
||||||
|
messageBlockNumber: blockNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNotificationNavigation(row, routePath, navigate) {
|
||||||
|
if (!routePath || typeof navigate !== 'function') return;
|
||||||
|
|
||||||
|
row.classList.add('notification-card--clickable');
|
||||||
|
row.tabIndex = 0;
|
||||||
|
row.setAttribute('role', 'link');
|
||||||
|
|
||||||
|
const open = () => navigate(routePath);
|
||||||
|
row.addEventListener('click', open);
|
||||||
|
row.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(item, activeTab, navigate) {
|
||||||
|
const row = document.createElement('article');
|
||||||
|
row.className = 'card stack notification-card';
|
||||||
|
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||||
|
row.append(renderIdentity(item));
|
||||||
|
|
||||||
|
const action = document.createElement('p');
|
||||||
|
action.className = 'notification-action';
|
||||||
|
if (activeTab === 'events') {
|
||||||
|
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||||
|
} else {
|
||||||
|
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||||
|
}
|
||||||
|
row.append(action);
|
||||||
|
|
||||||
|
if (activeTab === 'replies') {
|
||||||
|
const body = document.createElement('p');
|
||||||
|
body.className = 'notification-content';
|
||||||
|
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||||
|
row.append(body);
|
||||||
|
|
||||||
|
const engagement = renderEngagement(item.engagement);
|
||||||
|
if (engagement) row.append(engagement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate, chrome } = {}) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack notifications-screen';
|
screen.className = 'stack notifications-screen';
|
||||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
tabs.innerHTML = `
|
tabs.innerHTML = `
|
||||||
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
<button
|
||||||
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
type="button"
|
||||||
|
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
||||||
|
data-tab="replies"
|
||||||
|
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||||
|
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||||||
|
>Ответы</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
||||||
|
data-tab="events"
|
||||||
|
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||||
|
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||||||
|
>События</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack notifications-list';
|
list.className = 'stack notifications-list';
|
||||||
renderList(list);
|
|
||||||
|
|
||||||
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
let requestSeq = 0;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const seq = ++requestSeq;
|
||||||
|
const activeTab = state.notificationsTab;
|
||||||
|
list.replaceChildren(renderEmpty(activeTab));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await authService.getNotifications(50);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
||||||
|
.map(normalizeItem);
|
||||||
|
if (!baseItems.length) {
|
||||||
|
list.replaceChildren(renderEmpty(activeTab));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'card stack';
|
||||||
|
const title = document.createElement('strong');
|
||||||
|
title.textContent = 'Не удалось загрузить уведомления';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'meta-muted';
|
||||||
|
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
||||||
|
card.append(title, text);
|
||||||
|
list.replaceChildren(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNotificationTab(nextTab) {
|
||||||
|
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||||||
|
state.notificationsTab = normalizedTab;
|
||||||
|
|
||||||
|
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||||||
|
const selected = node.dataset.tab === normalizedTab;
|
||||||
|
node.classList.toggle('is-active', selected);
|
||||||
|
node.dataset.selected = selected ? 'true' : 'false';
|
||||||
|
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||||
|
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||||||
|
setActiveNotificationTab(state.notificationsTab);
|
||||||
|
|
||||||
|
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
state.notificationsTab = btn.dataset.tab;
|
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||||
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
if (state.notificationsTab === nextTab) {
|
||||||
btn.classList.add('active');
|
setActiveNotificationTab(nextTab);
|
||||||
renderList(list);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveNotificationTab(nextTab);
|
||||||
|
void load();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.append(tabs, list);
|
screen.append(tabs, list);
|
||||||
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@@ -101,22 +102,92 @@ export function render({ navigate, chrome }) {
|
|||||||
const topbar = document.createElement('header');
|
const topbar = document.createElement('header');
|
||||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||||
topbar.innerHTML = `
|
topbar.innerHTML = `
|
||||||
<div class="header-actions profile-top-actions">
|
<div class="header-left" aria-hidden="true"></div>
|
||||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="profile" aria-label="Редактировать профиль" title="Редактировать профиль">
|
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||||
</button>
|
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
|
||||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button class="ghost-btn profile-top-action-btn profile-top-icon-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки">
|
|
||||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const topActions = topbar.querySelector('.profile-top-actions');
|
|
||||||
topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
profileMenuButton?.append(createOverflowDots());
|
||||||
|
let profileMenuPortal = null;
|
||||||
|
|
||||||
|
const closeProfileMenu = () => {
|
||||||
|
profileMenuPortal?.remove();
|
||||||
|
profileMenuPortal = null;
|
||||||
|
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||||
|
profileMenuWrap?.classList.remove('is-open');
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionProfileMenu = () => {
|
||||||
|
if (!profileMenuPortal || !profileMenuButton) return;
|
||||||
|
const rect = profileMenuButton.getBoundingClientRect();
|
||||||
|
const margin = 10;
|
||||||
|
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||||
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||||
|
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||||
|
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProfileMenu = () => {
|
||||||
|
if (!profileMenuButton || profileMenuPortal) return;
|
||||||
|
const portal = document.createElement('div');
|
||||||
|
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||||
|
portal.setAttribute('role', 'menu');
|
||||||
|
portal.innerHTML = `
|
||||||
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||||
|
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||||
|
<span>Редактировать профиль</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||||
|
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||||
|
<span>Кошелёк</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||||
|
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||||
|
<span>Настройки</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const goTo = (route) => {
|
||||||
|
closeProfileMenu();
|
||||||
|
navigate(route);
|
||||||
|
};
|
||||||
|
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||||
|
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||||
|
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||||
|
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
|
||||||
|
document.body.append(portal);
|
||||||
|
profileMenuPortal = portal;
|
||||||
|
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||||
|
profileMenuWrap?.classList.add('is-open');
|
||||||
|
positionProfileMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
profileMenuButton?.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (profileMenuPortal) closeProfileMenu();
|
||||||
|
else openProfileMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!profileMenuPortal) return;
|
||||||
|
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||||
|
closeProfileMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||||
|
closeProfileMenu();
|
||||||
|
profileMenuButton?.focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||||
|
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||||
|
|
||||||
chrome?.setTopbar(topbar);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|||||||
@@ -2882,6 +2882,14 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNotifications(limit = 50) {
|
||||||
|
const payload = {};
|
||||||
|
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||||
|
const response = await this.ws.request('GetNotifications', payload);
|
||||||
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getUserConnectionsGraph(login) {
|
async getUserConnectionsGraph(login) {
|
||||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/*
|
||||||
|
* Единый визуальный язык кнопок основного приложения:
|
||||||
|
* белое содержимое, без рамок и самостоятельной подложки.
|
||||||
|
*
|
||||||
|
* Исключения:
|
||||||
|
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
||||||
|
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root a.primary-btn,
|
||||||
|
:root a.secondary-btn,
|
||||||
|
:root a.destructive-btn,
|
||||||
|
:root a.ghost-btn,
|
||||||
|
:root a.icon-btn,
|
||||||
|
:root a.text-btn {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
transition:
|
||||||
|
transform 90ms ease,
|
||||||
|
box-shadow 90ms ease,
|
||||||
|
background-color 90ms ease,
|
||||||
|
filter 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root a.primary-btn:hover,
|
||||||
|
:root a.secondary-btn:hover,
|
||||||
|
:root a.destructive-btn:hover,
|
||||||
|
:root a.ghost-btn:hover,
|
||||||
|
:root a.icon-btn:hover,
|
||||||
|
:root a.text-btn:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
||||||
|
* Эффект существует только пока кнопка физически нажата.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root a.primary-btn:active,
|
||||||
|
:root a.secondary-btn:active,
|
||||||
|
:root a.destructive-btn:active,
|
||||||
|
:root a.ghost-btn:active,
|
||||||
|
:root a.icon-btn:active,
|
||||||
|
:root a.text-btn:active {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: rgba(0, 0, 0, 0.12) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
transform: translateY(1px) scale(0.97);
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
||||||
|
:root a.primary-btn[aria-disabled='true'],
|
||||||
|
:root a.secondary-btn[aria-disabled='true'],
|
||||||
|
:root a.destructive-btn[aria-disabled='true'],
|
||||||
|
:root a.ghost-btn[aria-disabled='true'],
|
||||||
|
:root a.icon-btn[aria-disabled='true'],
|
||||||
|
:root a.text-btn[aria-disabled='true'] {
|
||||||
|
color: rgba(255, 255, 255, 0.42) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
||||||
|
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
||||||
|
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
||||||
|
*/
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
||||||
|
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
||||||
|
*/
|
||||||
|
:root .toolbar-btn {
|
||||||
|
transition:
|
||||||
|
transform 90ms ease,
|
||||||
|
box-shadow 90ms ease,
|
||||||
|
background-color 90ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .toolbar-btn:active {
|
||||||
|
transform: translateY(1px) scale(0.96);
|
||||||
|
background: rgba(0, 0, 0, 0.14) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
||||||
|
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root a.primary-btn:focus-visible,
|
||||||
|
:root a.secondary-btn:focus-visible,
|
||||||
|
:root a.destructive-btn:focus-visible,
|
||||||
|
:root a.ghost-btn:focus-visible,
|
||||||
|
:root a.icon-btn:focus-visible,
|
||||||
|
:root a.text-btn:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Уведомления: «Ответы / События».
|
||||||
|
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
||||||
|
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
||||||
|
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
||||||
|
*/
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: rgba(0, 0, 0, 0.18) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||||
|
transform: translateY(1px) scale(0.965) !important;
|
||||||
|
filter: brightness(0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
||||||
|
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
||||||
|
*/
|
||||||
|
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
||||||
|
background: rgba(0, 0, 0, 0.12) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||||
|
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||||
|
transform: translateY(1px) scale(0.97) !important;
|
||||||
|
filter: brightness(0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
||||||
|
* Это правило намеренно расположено после глобального белого button-rule. */
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||||
|
color: var(--app-topbar-gold) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
||||||
|
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||||
|
color: #F7FBFF !important;
|
||||||
|
text-shadow:
|
||||||
|
0 0 5px rgba(92, 190, 255, 0.72),
|
||||||
|
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||||
|
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
outline: none !important;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
||||||
|
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn,
|
||||||
|
:root .dm-chat-input button.dm-send-btn,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close,
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn:hover,
|
||||||
|
:root .dm-chat-input button.dm-send-btn:hover,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
||||||
|
:root .dm-chat-input button.dm-emoji-btn:focus,
|
||||||
|
:root .dm-chat-input button.dm-send-btn:focus,
|
||||||
|
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
||||||
|
color: #F7FBFF !important;
|
||||||
|
text-shadow:
|
||||||
|
0 0 5px rgba(92, 190, 255, 0.72),
|
||||||
|
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
||||||
|
}
|
||||||
+1469
-5
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
.fg-orb-host .fg-pngorb-init {
|
.fg-orb-host .fg-pngorb-init {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||||
.fg-filter-bar {
|
.fg-filter-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
|
|||||||
Reference in New Issue
Block a user