SHA256
Compare commits
18
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a | ||
|
|
0c5089fa79 | ||
|
|
e5556f3706 | ||
|
|
09c7f8541e | ||
|
|
8eb2ef438c | ||
|
|
34abe14151 | ||
|
|
90e37c198f | ||
|
|
1373283947 | ||
|
|
065616a18b | ||
|
|
53cd55a2a0 | ||
|
|
079be37fff | ||
|
|
806a8c57d4 | ||
|
|
797e769cc3 | ||
|
|
822a72e246 | ||
|
|
8801b93973 |
Binary file not shown.
@@ -36,6 +36,7 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_17 = 17;
|
public static final int SCHEMA_VERSION_17 = 17;
|
||||||
public static final int SCHEMA_VERSION_18 = 18;
|
public static final int SCHEMA_VERSION_18 = 18;
|
||||||
public static final int SCHEMA_VERSION_19 = 19;
|
public static final int SCHEMA_VERSION_19 = 19;
|
||||||
|
public static final int SCHEMA_VERSION_20 = 20;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -55,6 +56,7 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -200,6 +202,10 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_19;
|
currentVersion = SCHEMA_VERSION_19;
|
||||||
}
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_20) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,16 +77,18 @@ public final class DmDialogStateDAO {
|
|||||||
ps.setString(1, cleanOwner);
|
ps.setString(1, cleanOwner);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||||
|
int unreadCount = rs.getInt("unread_count");
|
||||||
DialogSummary row = new DialogSummary(
|
DialogSummary row = new DialogSummary(
|
||||||
rs.getString("owner_login"),
|
rs.getString("owner_login"),
|
||||||
rs.getString("peer_login"),
|
rs.getString("peer_login"),
|
||||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||||
rs.getString("last_message_blob_b64"),
|
rs.getString("last_message_blob_b64"),
|
||||||
rs.getLong("last_message_time_ms"),
|
lastMessageTimeMs,
|
||||||
rs.getInt("unread_count"),
|
unreadCount,
|
||||||
rs.getLong("last_read_receipt_time_ms"),
|
rs.getLong("last_read_receipt_time_ms"),
|
||||||
rs.getLong("updated_at_ms"),
|
rs.getLong("updated_at_ms"),
|
||||||
true
|
lastMessageTimeMs > 0 || unreadCount > 0
|
||||||
);
|
);
|
||||||
byPeer.put(normKey(row.peerLogin()), row);
|
byPeer.put(normKey(row.peerLogin()), row);
|
||||||
}
|
}
|
||||||
@@ -364,6 +366,8 @@ public final class DmDialogStateDAO {
|
|||||||
String nextRelation = current.relationFlag();
|
String nextRelation = current.relationFlag();
|
||||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "close_friend";
|
nextRelation = "close_friend";
|
||||||
|
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "friend";
|
||||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "contact";
|
nextRelation = "contact";
|
||||||
} else {
|
} else {
|
||||||
@@ -440,7 +444,7 @@ public final class DmDialogStateDAO {
|
|||||||
|
|
||||||
private String normalizeRelationFlag(String value) {
|
private String normalizeRelationFlag(String value) {
|
||||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
public final class UserNotificationSeenStateDAO {
|
||||||
|
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||||
|
private UserNotificationSeenStateDAO() {}
|
||||||
|
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||||
|
|
||||||
|
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||||
|
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||||
|
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||||
|
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||||
|
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return getSeenAt(c, login, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) 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 = ?
|
||||||
|
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
ps.setLong(4, Math.max(0, cutoffMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||||
|
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserNotificationEntry e = new UserNotificationEntry();
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
e.setOwnerLogin(rs.getString("owner_login"));
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import java.sql.SQLException;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
|
||||||
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||||
public final class UserProfileStateDAO {
|
public final class UserProfileStateDAO {
|
||||||
private static volatile UserProfileStateDAO instance;
|
private static volatile UserProfileStateDAO instance;
|
||||||
@@ -31,6 +33,29 @@ public final class UserProfileStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||||
|
*
|
||||||
|
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
|
||||||
|
* older/current blocks may address a user by blockchain name, so a direct to_login-only
|
||||||
|
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
|
||||||
|
*/
|
||||||
|
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
|
||||||
|
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||||
|
return "close_friend";
|
||||||
|
}
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||||
|
return "friend";
|
||||||
|
}
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
||||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||||
int safeOffset = Math.max(0, offset);
|
int safeOffset = Math.max(0, offset);
|
||||||
@@ -94,14 +119,14 @@ public final class UserProfileStateDAO {
|
|||||||
sql="""
|
sql="""
|
||||||
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||||
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||||
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ?
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
""";
|
""";
|
||||||
} else if ("following".equals(mode)) {
|
} else if ("following".equals(mode)) {
|
||||||
sql="""
|
sql="""
|
||||||
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||||
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
||||||
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ?
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
""";
|
""";
|
||||||
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||||
List<ChannelCard> out=new ArrayList<>();
|
List<ChannelCard> out=new ArrayList<>();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Notifications v2: three categories + signed seen watermarks.
|
||||||
|
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||||
|
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 18, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
source_login TEXT NOT NULL,
|
source_login TEXT NOT NULL,
|
||||||
source_bch_name TEXT NOT NULL,
|
source_bch_name TEXT NOT NULL,
|
||||||
@@ -1997,8 +1997,20 @@ UPDATE message_stats ms SET
|
|||||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
VALUES(1,18,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
|
|||||||
+4
@@ -95,10 +95,12 @@ import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfi
|
|||||||
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.notifications.Net_GetNotifications_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_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.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_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;
|
||||||
@@ -215,6 +217,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||||
|
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
@@ -305,6 +308,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||||
|
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
|
|||||||
+17
-10
@@ -916,16 +916,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection notifications are intentionally modeled as a generic kind.
|
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||||
// Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent.
|
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
if (msgType == 3
|
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
&& (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF))
|
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
&& block.body instanceof ConnectionBody) {
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
entry.setNotificationKind("connection");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
entry.setSourceText("");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
return entry;
|
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||||
|
if (personalConnection || channelEvent) {
|
||||||
|
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||||
|
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||||
|
entry.setSourceText("");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+11
@@ -46,6 +46,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
item.setFirstName(card.firstName());
|
item.setFirstName(card.firstName());
|
||||||
item.setLastName(card.lastName());
|
item.setLastName(card.lastName());
|
||||||
item.setAvatarAr(card.avatarAr());
|
item.setAvatarAr(card.avatarAr());
|
||||||
|
item.setAvatar(parseAvatar(card.avatarAr()));
|
||||||
item.setAccountRole(card.accountRole());
|
item.setAccountRole(card.accountRole());
|
||||||
item.setShineStatus(card.shineStatus());
|
item.setShineStatus(card.shineStatus());
|
||||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||||
@@ -56,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
|
||||||
|
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
|
||||||
|
String raw = value == null ? "" : value.trim();
|
||||||
|
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||||
|
if (ar.find()) out.setAr(ar.group(1));
|
||||||
|
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||||
|
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -20,6 +20,7 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
private String firstName;
|
private String firstName;
|
||||||
private String lastName;
|
private String lastName;
|
||||||
private String avatarAr;
|
private String avatarAr;
|
||||||
|
private Avatar avatar;
|
||||||
private String accountRole;
|
private String accountRole;
|
||||||
private String shineStatus;
|
private String shineStatus;
|
||||||
private String lastMessageBlobB64;
|
private String lastMessageBlobB64;
|
||||||
@@ -37,6 +38,8 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
public String getAvatarAr() { return avatarAr; }
|
public String getAvatarAr() { return avatarAr; }
|
||||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||||
|
public Avatar getAvatar() { return avatar; }
|
||||||
|
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||||
public String getAccountRole() { return accountRole; }
|
public String getAccountRole() { return accountRole; }
|
||||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||||
public String getShineStatus() { return shineStatus; }
|
public String getShineStatus() { return shineStatus; }
|
||||||
@@ -50,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public boolean isHasDialog() { return hasDialog; }
|
public boolean isHasDialog() { return hasDialog; }
|
||||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Avatar {
|
||||||
|
private String ar;
|
||||||
|
private String sha256Hex;
|
||||||
|
public String getAr() { return ar; }
|
||||||
|
public void setAr(String ar) { this.ar = ar; }
|
||||||
|
public String getSha256Hex() { return sha256Hex; }
|
||||||
|
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-89
@@ -1,94 +1,27 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
|
||||||
import org.slf4j.LoggerFactory;
|
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import shine.db.DbController; import shine.db.dao.*; import shine.db.entities.UserNotificationEntry;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import java.sql.Connection; import java.util.*;
|
||||||
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 {
|
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); private static final long HISTORY_MS=60L*24*60*60*1000;
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
@Override
|
Net_GetNotifications_Request req=(Net_GetNotifications_Request)base; if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Операция доступна только для авторизованных пользователей");
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
try(Connection c=DbController.getInstance().getConnection()){
|
||||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||||
return NetExceptionResponseFactory.error(
|
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||||
req,
|
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||||
WireCodes.Status.UNVERIFIED,
|
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||||
"NOT_AUTHENTICATED",
|
r.setReplies(map(nd.listVisible(c,login,"reply",rs,cutoff))); r.setConnections(map(nd.listVisible(c,login,"connection",cs,cutoff))); r.setEvents(map(nd.listVisible(c,login,"event",es,cutoff)));
|
||||||
"Операция доступна только для авторизованных пользователей"
|
}
|
||||||
);
|
r.setRepliesSeenAtMs(rs);r.setConnectionsSeenAtMs(cs);r.setEventsSeenAtMs(es); r.setRepliesUnseenCount(nd.countUnseen(c,login,"reply",rs)); r.setConnectionsUnseenCount(nd.countUnseen(c,login,"connection",cs)); r.setEventsUnseenCount(nd.countUnseen(c,login,"event",es));
|
||||||
}
|
return r;
|
||||||
|
}catch(Exception e){log.error("GetNotifications failed",e);return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"internal_error","Внутренняя ошибка сервера");}
|
||||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
}
|
||||||
if (login.isBlank()) {
|
private List<Net_GetNotifications_Response.NotificationItem> map(List<UserNotificationEntry> rows){ List<Net_GetNotifications_Response.NotificationItem> out=new ArrayList<>(); for(UserNotificationEntry x:rows){ Net_GetNotifications_Response.NotificationItem i=new Net_GetNotifications_Response.NotificationItem(); i.setKind(x.getNotificationKind());i.setCreatedAtMs(x.getCreatedAtMs());i.setSourceLogin(x.getSourceLogin());i.setSourceBlockchainName(x.getSourceBchName());i.setSourceBlockNumber(x.getSourceBlockNumber());i.setSourceBlockHash(hex(x.getSourceBlockHash()));i.setSourceMsgSubType(x.getSourceMsgSubType());i.setConnectionTypeCode("connection".equals(x.getNotificationKind())?x.getSourceMsgSubType():null);i.setSourceText(x.getSourceText());i.setTargetLogin(x.getTargetLogin());i.setTargetBlockchainName(x.getTargetBchName());i.setTargetBlockNumber(x.getTargetBlockNumber());i.setTargetBlockHash(hex(x.getTargetBlockHash()));out.add(i);} return out; }
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
private static String hex(byte[] b){if(b==null)return null;StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
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.*;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public final class Net_SetNotificationState_Handler implements JsonMessageHandler {
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
|
Net_SetNotificationState_Request req=(Net_SetNotificationState_Request)base;
|
||||||
|
if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Требуется авторизация");
|
||||||
|
try{
|
||||||
|
byte[] raw=Base64.getDecoder().decode(String.valueOf(req.getBlobB64()).trim()); NotificationStatePacket p=NotificationStatePacket.parse(raw);
|
||||||
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim(); if(!login.equalsIgnoreCase(p.login)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"LOGIN_MISMATCH","Подпись принадлежит другому пользователю");
|
||||||
|
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
||||||
|
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
||||||
|
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
||||||
|
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
|
||||||
|
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
final class NotificationStatePacket {
|
||||||
|
static final byte[] PREFIX = "SHiNE_NTF".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
static final int STATE_SEEN_WATERMARK = 1;
|
||||||
|
static final int CATEGORY_REPLIES = 1;
|
||||||
|
static final int CATEGORY_CONNECTIONS = 2;
|
||||||
|
static final int CATEGORY_EVENTS = 3;
|
||||||
|
|
||||||
|
final String login; final long timeMs; final long nonce; final int stateType; final int category; final long seenAtMs;
|
||||||
|
final byte[] signedBody; final byte[] signature64; final byte[] rawPacket;
|
||||||
|
private NotificationStatePacket(String login,long timeMs,long nonce,int stateType,int category,long seenAtMs,byte[] signedBody,byte[] signature64,byte[] rawPacket){
|
||||||
|
this.login=login;this.timeMs=timeMs;this.nonce=nonce;this.stateType=stateType;this.category=category;this.seenAtMs=seenAtMs;this.signedBody=signedBody;this.signature64=signature64;this.rawPacket=rawPacket;
|
||||||
|
}
|
||||||
|
static NotificationStatePacket parse(byte[] raw) {
|
||||||
|
if(raw==null||raw.length<PREFIX.length+2+1+1+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
for(int i=0;i<PREFIX.length;i++) if(raw[i]!=PREFIX[i]) throw new IllegalArgumentException("BAD_PREFIX");
|
||||||
|
ByteBuffer bb=ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); bb.position(PREFIX.length);
|
||||||
|
int major=Byte.toUnsignedInt(bb.get()), minor=Byte.toUnsignedInt(bb.get());
|
||||||
|
if(major!=1||minor!=0) throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
int len=Byte.toUnsignedInt(bb.get()); if(len<1||len>60||bb.remaining()<len+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
byte[] lb=new byte[len]; bb.get(lb); for(byte b:lb) if(b<0x20||b>0x7e) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
String login=new String(lb,StandardCharsets.US_ASCII); long timeMs=bb.getLong(); if(timeMs<0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
|
long nonce=Integer.toUnsignedLong(bb.getInt()); int stateType=Byte.toUnsignedInt(bb.get()); if(stateType!=STATE_SEEN_WATERMARK) throw new IllegalArgumentException("BAD_STATE_TYPE");
|
||||||
|
int category=Byte.toUnsignedInt(bb.get()); if(category<1||category>3) throw new IllegalArgumentException("BAD_CATEGORY");
|
||||||
|
long seenAtMs=bb.getLong(); if(seenAtMs<0||bb.remaining()!=64) throw new IllegalArgumentException("BAD_SEEN_TIME");
|
||||||
|
byte[] sig=new byte[64]; bb.get(sig); return new NotificationStatePacket(login,timeMs,nonce,stateType,category,seenAtMs,Arrays.copyOf(raw,raw.length-64),sig,raw);
|
||||||
|
}
|
||||||
|
String categoryName(){ return category==CATEGORY_REPLIES?"replies":category==CATEGORY_CONNECTIONS?"connections":"events"; }
|
||||||
|
}
|
||||||
+4
-2
@@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_GetNotifications_Request extends Net_Request {
|
public class Net_GetNotifications_Request extends Net_Request {
|
||||||
private Integer limit;
|
private Integer limit; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
|
||||||
|
private Boolean countsOnly;
|
||||||
public Integer getLimit() { return limit; }
|
public Integer getLimit() { return limit; }
|
||||||
public void setLimit(Integer limit) { this.limit = limit; }
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getCountsOnly() { return countsOnly; }
|
||||||
|
public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -8,12 +8,23 @@ import java.util.List;
|
|||||||
public class Net_GetNotifications_Response extends Net_Response {
|
public class Net_GetNotifications_Response extends Net_Response {
|
||||||
private String login;
|
private String login;
|
||||||
private List<NotificationItem> replies = new ArrayList<>();
|
private List<NotificationItem> replies = new ArrayList<>();
|
||||||
|
private List<NotificationItem> connections = new ArrayList<>();
|
||||||
private List<NotificationItem> events = new ArrayList<>();
|
private List<NotificationItem> events = new ArrayList<>();
|
||||||
|
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||||
|
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
public List<NotificationItem> getReplies() { return replies; }
|
public List<NotificationItem> getReplies() { return replies; }
|
||||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||||
|
public List<NotificationItem> getConnections() { return connections; }
|
||||||
|
public void setConnections(List<NotificationItem> v) { connections = v; }
|
||||||
|
public long getRepliesSeenAtMs(){return repliesSeenAtMs;} public void setRepliesSeenAtMs(long v){repliesSeenAtMs=v;}
|
||||||
|
public long getConnectionsSeenAtMs(){return connectionsSeenAtMs;} public void setConnectionsSeenAtMs(long v){connectionsSeenAtMs=v;}
|
||||||
|
public long getEventsSeenAtMs(){return eventsSeenAtMs;} public void setEventsSeenAtMs(long v){eventsSeenAtMs=v;}
|
||||||
|
public long getRepliesUnseenCount(){return repliesUnseenCount;} public void setRepliesUnseenCount(long v){repliesUnseenCount=v;}
|
||||||
|
public long getConnectionsUnseenCount(){return connectionsUnseenCount;} public void setConnectionsUnseenCount(long v){connectionsUnseenCount=v;}
|
||||||
|
public long getEventsUnseenCount(){return eventsUnseenCount;} public void setEventsUnseenCount(long v){eventsUnseenCount=v;}
|
||||||
public List<NotificationItem> getEvents() { return events; }
|
public List<NotificationItem> getEvents() { return events; }
|
||||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||||
|
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
public class Net_SetNotificationState_Request extends Net_Request { private String blobB64; public String getBlobB64(){return blobB64;} public void setBlobB64(String v){blobB64=v;} }
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
public class Net_SetNotificationState_Response extends Net_Response { private String category; private long seenAtMs; public String getCategory(){return category;} public void setCategory(String v){category=v;} public long getSeenAtMs(){return seenAtMs;} public void setSeenAtMs(long v){seenAtMs=v;} }
|
||||||
+36
@@ -11,6 +11,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
|
|||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
|
import shine.db.dao.UserProfileStateDAO;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.entities.DmDeliveryStateEntry;
|
import shine.db.entities.DmDeliveryStateEntry;
|
||||||
import shine.db.entities.SignedMessageEntry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
@@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
|||||||
resp.setLimit(limit);
|
resp.setLimit(limit);
|
||||||
resp.setHasMore(hasMore);
|
resp.setHasMore(hasMore);
|
||||||
|
|
||||||
|
// Return a normalized peer card together with the dialog. This makes the
|
||||||
|
// chat header self-contained even when /chat/<login> is opened directly.
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin);
|
||||||
|
Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard();
|
||||||
|
peer.setLogin(peerLogin);
|
||||||
|
peer.setFirstName(card.firstName());
|
||||||
|
peer.setLastName(card.lastName());
|
||||||
|
peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin));
|
||||||
|
peer.setAccountRole(card.accountRole());
|
||||||
|
peer.setShineStatus(card.shineStatus());
|
||||||
|
peer.setAvatar(parseAvatar(card.avatarAr()));
|
||||||
|
resp.setPeer(peer);
|
||||||
|
}
|
||||||
|
|
||||||
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||||
for (SignedMessageEntry entry : page) {
|
for (SignedMessageEntry entry : page) {
|
||||||
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||||
@@ -108,4 +127,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) {
|
||||||
|
Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar();
|
||||||
|
String raw = value == null ? "" : value.trim();
|
||||||
|
|
||||||
|
// Do not call Matcher.find() twice on the same matcher: the first successful
|
||||||
|
// call advances it and the second one can make a perfectly valid avatar vanish.
|
||||||
|
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||||
|
if (ar.find()) {
|
||||||
|
out.setAr(ar.group(1));
|
||||||
|
}
|
||||||
|
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||||
|
if (sha.find()) {
|
||||||
|
out.setSha256Hex(sha.group(1).toLowerCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -12,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
|||||||
private boolean hasMore;
|
private boolean hasMore;
|
||||||
private Long nextBeforeTimeMs;
|
private Long nextBeforeTimeMs;
|
||||||
private String nextBeforeMessageKey;
|
private String nextBeforeMessageKey;
|
||||||
|
private PeerCard peer;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
@@ -26,9 +27,46 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
|||||||
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||||
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||||
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||||
|
public PeerCard getPeer() { return peer; }
|
||||||
|
public void setPeer(PeerCard peer) { this.peer = peer; }
|
||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
|
||||||
|
public static class PeerCard {
|
||||||
|
private String login;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private Avatar avatar;
|
||||||
|
private String relationType;
|
||||||
|
private String accountRole;
|
||||||
|
private String shineStatus;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
public Avatar getAvatar() { return avatar; }
|
||||||
|
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||||
|
public String getRelationType() { return relationType; }
|
||||||
|
public void setRelationType(String relationType) { this.relationType = relationType; }
|
||||||
|
public String getAccountRole() { return accountRole; }
|
||||||
|
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||||
|
public String getShineStatus() { return shineStatus; }
|
||||||
|
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Avatar {
|
||||||
|
private String ar;
|
||||||
|
private String sha256Hex;
|
||||||
|
public String getAr() { return ar; }
|
||||||
|
public void setAr(String ar) { this.ar = ar; }
|
||||||
|
public String getSha256Hex() { return sha256Hex; }
|
||||||
|
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class MessageItem {
|
public static class MessageItem {
|
||||||
private String messageKey;
|
private String messageKey;
|
||||||
private String baseKey;
|
private String baseKey;
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.0
|
client.version=1.12.0
|
||||||
server.version=1.9.0
|
server.version=1.10.0
|
||||||
|
|||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Build an offline-ready source bundle ZIP.
|
||||||
|
# In addition to the normal source tree, this variant can attach a local
|
||||||
|
# Gradle distribution zip and a helper script that rewrites wrapper URLs to
|
||||||
|
# that local file so the bundle can be used without internet access.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bundle-offline.sh
|
||||||
|
# ./bundle-offline.sh path/to/output.zip
|
||||||
|
#
|
||||||
|
# Expected local asset:
|
||||||
|
# offline/gradle-offline.zip
|
||||||
|
# or a custom path via BUNDLE_OFFLINE_GRADLE_ZIP
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
OUT="${1:-SHiNE-bundle-offline-$(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"
|
||||||
|
STAGE="$TMP/stage"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
# 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/*|\
|
||||||
|
*/SHiNE-bundle-*.zip|\
|
||||||
|
*/bundle-offline*.zip)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
find_offline_gradle_zip() {
|
||||||
|
local candidate="${BUNDLE_OFFLINE_GRADLE_ZIP:-}"
|
||||||
|
if [[ -n "$candidate" && -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
"$ROOT/offline/gradle-offline.zip" \
|
||||||
|
"$ROOT/offline/gradle-8.14-bin.zip" \
|
||||||
|
"$ROOT/offline/gradle.zip"
|
||||||
|
do
|
||||||
|
if [[ -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
create_offline_helper() {
|
||||||
|
local zip_name="$1"
|
||||||
|
local helper="$STAGE/offline/prepare-local-gradle.sh"
|
||||||
|
local readme="$STAGE/offline/README.txt"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
|
||||||
|
cat > "$helper" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
|
ZIP_PATH="\${1:-\$ROOT/offline/$zip_name}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ZIP_PATH" ]]; then
|
||||||
|
echo "ERROR: offline Gradle zip not found: \$ZIP_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ABS_ZIP="\$(cd -- "\$(dirname -- "\$ZIP_PATH")" && pwd -P)/\$(basename -- "\$ZIP_PATH")"
|
||||||
|
ESCAPED_ABS_ZIP="\${ABS_ZIP//\\\\/\\\\\\\\}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//&/\\\\&}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//|/\\\\|}"
|
||||||
|
|
||||||
|
while IFS= read -r props; do
|
||||||
|
[[ -f "\$props" ]] || continue
|
||||||
|
cp -p "\$props" "\$props.bak"
|
||||||
|
sed -i -e "s|^distributionUrl=.*\$|distributionUrl=file://\$ESCAPED_ABS_ZIP|" "\$props"
|
||||||
|
done < <(find "\$ROOT" -path '*/gradle/wrapper/gradle-wrapper.properties' -type f | sort)
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Gradle wrapper URLs rewritten to the local offline zip.
|
||||||
|
Run now:
|
||||||
|
./gradlew --offline test
|
||||||
|
MSG
|
||||||
|
EOF
|
||||||
|
chmod +x "$helper"
|
||||||
|
|
||||||
|
cat > "$readme" <<EOF
|
||||||
|
Offline Gradle helper
|
||||||
|
|
||||||
|
Included archive:
|
||||||
|
offline/$zip_name
|
||||||
|
|
||||||
|
Helper:
|
||||||
|
offline/prepare-local-gradle.sh
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- backs up each gradle-wrapper.properties as .bak
|
||||||
|
- rewrites wrapper distributionUrl to the local zip in this bundle
|
||||||
|
|
||||||
|
Recommended flow after unpacking:
|
||||||
|
1. cd into the unpacked bundle root
|
||||||
|
2. run ./offline/prepare-local-gradle.sh
|
||||||
|
3. run ./gradlew --offline test
|
||||||
|
|
||||||
|
This bundle is intended for local, network-free verification.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Always include Gradle wrapper bootstrap, even though generic JARs are denied.
|
||||||
|
for wrapper_jar in \
|
||||||
|
'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' \
|
||||||
|
'SHiNE-browser-plugin-wallet/gradle/wrapper/gradle-wrapper.jar'
|
||||||
|
do
|
||||||
|
if [[ -f "$ROOT/$wrapper_jar" ]] && ! grep -Fxq "$wrapper_jar" "$LIST"; then
|
||||||
|
printf '%s\n' "$wrapper_jar" >> "$LIST"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
OFFLINE_ZIP_SRC=""
|
||||||
|
OFFLINE_ZIP_NAME=""
|
||||||
|
if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||||
|
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||||
|
else
|
||||||
|
echo "ERROR: offline Gradle zip not found." >&2
|
||||||
|
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
||||||
|
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
src="$ROOT/$rel"
|
||||||
|
dst="$STAGE/$rel"
|
||||||
|
mkdir -p "$(dirname -- "$dst")"
|
||||||
|
cp -p "$src" "$dst"
|
||||||
|
done < "$SAFE_LIST"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
cp -p "$OFFLINE_ZIP_SRC" "$STAGE/offline/$OFFLINE_ZIP_NAME"
|
||||||
|
create_offline_helper "$OFFLINE_ZIP_NAME"
|
||||||
|
|
||||||
|
rm -f -- "$OUT"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$STAGE"
|
||||||
|
find . -type f -print | sort | zip -q -9 "$OUT" -@
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Created: $OUT"
|
||||||
|
echo "Files: $(cd "$STAGE" && find . -type f | wc -l | tr -d ' ')"
|
||||||
|
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
| `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` | ответы и события уведомлений |
|
| `GetNotifications` | `15_Notifications_API.md` | ответы, связи, события и unread-watermark |
|
||||||
|
| `SetNotificationState` | `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 |
|
||||||
|
|||||||
@@ -1,81 +1,42 @@
|
|||||||
# API для разработчиков: уведомления
|
# API для разработчиков: уведомления
|
||||||
|
|
||||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
Уведомления являются серверной проекцией событий блокчейна. Сервер возвращает все непросмотренные записи независимо от возраста и просмотренные записи не старше 60 дней. Пагинации нет: выдача содержит все непросмотренные и всю доступную 60-дневную просмотренную историю.
|
||||||
|
|
||||||
Текущая операция:
|
## GetNotifications
|
||||||
|
|
||||||
- `GetNotifications`
|
Авторизация обязательна. Обычно payload пустой. Legacy-поле `limit` принимается для совместимости, но в v2 игнорируется. Для обновления badge без загрузки карточек можно передать `{"countsOnly":true}`; тогда массивы лент остаются пустыми, но watermark и `*UnseenCount` возвращаются.
|
||||||
|
|
||||||
## 1. `GetNotifications`
|
Ответ содержит три ленты: `replies`, `connections`, `events`, а также `*SeenAtMs` и `*UnseenCount` для каждой категории.
|
||||||
|
|
||||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
- `replies`: TEXT_REPLY.
|
||||||
|
- `connections`: friend/unfriend, close_friend/unclose_friend, shine confirmed/unconfirmed, official confirmed/unconfirmed. Контакты не создают уведомлений.
|
||||||
|
- `events`: FOLLOW/UNFOLLOW каналов.
|
||||||
|
|
||||||
Возвращаются две отдельные ленты:
|
Фильтр каждой категории: `created_at_ms > seenAtMs OR created_at_ms >= now - 60 days`.
|
||||||
|
|
||||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
## SetNotificationState
|
||||||
- `events` — события добавления в `close_friend`.
|
|
||||||
|
|
||||||
### Запрос
|
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
|
||||||
|
|
||||||
|
Запрос:
|
||||||
```json
|
```json
|
||||||
{
|
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
|
||||||
"op": "GetNotifications",
|
|
||||||
"requestId": "notif-001",
|
|
||||||
"payload": {
|
|
||||||
"login": "alice",
|
|
||||||
"limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
'SHiNE_NTF' 9 bytes ASCII
|
||||||
"op": "GetNotifications",
|
formatVersionMajor u8 = 1
|
||||||
"requestId": "notif-001",
|
formatVersionMinor u8 = 0
|
||||||
"status": 200,
|
loginLen u8
|
||||||
"ok": true,
|
login ASCII[loginLen]
|
||||||
"payload": {
|
timeMs u64
|
||||||
"login": "Alice",
|
nonce u32
|
||||||
"replies": [
|
stateType u8 = 1 (SEEN_WATERMARK)
|
||||||
{
|
category u8 (1 replies, 2 connections, 3 events)
|
||||||
"kind": "reply",
|
seenAtMs u64
|
||||||
"createdAtMs": 1755673200000,
|
signature Ed25519[64]
|
||||||
"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..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Примечание
|
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
|
||||||
|
|
||||||
- `replies` заполняется только для `TEXT_REPLY`.
|
|
||||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
|
||||||
- Другие типы связей в эту ленту не попадают.
|
|
||||||
|
|||||||
@@ -203,3 +203,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
|||||||
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||||
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||||
|
|
||||||
|
## Мультипрофильный клиент (UI, 2026-09-03)
|
||||||
|
- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM.
|
||||||
|
- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна.
|
||||||
|
- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие.
|
||||||
|
- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным.
|
||||||
|
- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались.
|
||||||
|
|
||||||
|
## UI: видимость пустого диалога после DeleteConversation
|
||||||
|
|
||||||
|
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
|
||||||
|
|
||||||
|
Для списка личных чатов действует правило:
|
||||||
|
|
||||||
|
- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается;
|
||||||
|
- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи;
|
||||||
|
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
|
||||||
|
|
||||||
|
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
|
||||||
|
|||||||
@@ -356,3 +356,12 @@ ReadReceiptBody_v1_0
|
|||||||
|
|
||||||
## Примечание UI списка чатов (2026-08-28)
|
## Примечание UI списка чатов (2026-08-28)
|
||||||
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||||
|
|
||||||
|
## Примечание о мультипрофиле (2026-09-03)
|
||||||
|
Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`).
|
||||||
|
|
||||||
|
## UI-семантика `type=7/8` в списке диалогов
|
||||||
|
|
||||||
|
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||||
|
|
||||||
|
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
|
||||||
|
|||||||
@@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
const existing = allClients.find((client) => {
|
const existing = allClients[0] || null;
|
||||||
try {
|
|
||||||
return client.url.includes('/index.html') || client.url.endsWith('/');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const openUrlBase = './index.html';
|
const openUrlBase = './index.html';
|
||||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||||
const openUrl = (action === 'accept' || action === 'decline')
|
const openUrl = (action === 'accept' || action === 'decline')
|
||||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||||
: openUrlBase;
|
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
try {
|
||||||
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
action,
|
action,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
existing.postMessage({
|
||||||
|
type: 'SHINE_NOTIFICATION_CLICK',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
await existing.focus();
|
await existing.focus();
|
||||||
|
|||||||
+101
-5
@@ -33,6 +33,9 @@ import {
|
|||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
hydrateMessagesFromStore,
|
hydrateMessagesFromStore,
|
||||||
|
getSavedProfiles,
|
||||||
|
closeSavedProfile,
|
||||||
|
switchToSavedProfile,
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
setSessionAuthorizedHandler,
|
setSessionAuthorizedHandler,
|
||||||
@@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
|||||||
|
|
||||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||||
import * as profileEditView from './pages/profile-edit-view.js';
|
import * as profileEditView from './pages/profile-edit-view.js';
|
||||||
|
import * as profilesView from './pages/profiles-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
import * as accessServersView from './pages/access-servers-view.js';
|
import * as accessServersView from './pages/access-servers-view.js';
|
||||||
@@ -132,6 +136,7 @@ const routes = {
|
|||||||
queue: publicSupportQueueView,
|
queue: publicSupportQueueView,
|
||||||
'profile-view': profileView,
|
'profile-view': profileView,
|
||||||
'profile-edit-view': profileEditView,
|
'profile-edit-view': profileEditView,
|
||||||
|
'profiles-view': profilesView,
|
||||||
'wallet-view': walletView,
|
'wallet-view': walletView,
|
||||||
'settings-view': settingsView,
|
'settings-view': settingsView,
|
||||||
'access-servers-view': accessServersView,
|
'access-servers-view': accessServersView,
|
||||||
@@ -213,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
|||||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||||
'settings-view',
|
'settings-view',
|
||||||
|
'profiles-view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||||
@@ -745,6 +751,77 @@ function consumeCallPushActionFromUrlIfAny() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function pushTargetLogin(payload = {}) {
|
||||||
|
return String(payload?.toLogin || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTargetPath(payload = {}) {
|
||||||
|
const kind = String(payload?.kind || '').trim();
|
||||||
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||||
|
if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`;
|
||||||
|
return '/profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedProfileExists(login) {
|
||||||
|
const normalized = String(login || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) {
|
||||||
|
const targetLogin = pushTargetLogin(payload);
|
||||||
|
const currentLogin = String(state.session.login || '').trim();
|
||||||
|
if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true;
|
||||||
|
if (!savedProfileExists(targetLogin)) {
|
||||||
|
showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = String(payload?.kind || '').trim();
|
||||||
|
const question = kind === 'incoming_call'
|
||||||
|
? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?`
|
||||||
|
: `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`;
|
||||||
|
if (!window.confirm(question)) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await switchToSavedProfile(targetLogin);
|
||||||
|
if (action === 'accept' || action === 'decline') {
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
window.location.assign(pushTargetPath(payload));
|
||||||
|
} else {
|
||||||
|
window.location.assign(pushTargetPath(payload));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotificationClick(payload = {}) {
|
||||||
|
const canOpen = await ensurePushTargetProfile(payload);
|
||||||
|
if (!canOpen) return;
|
||||||
|
const path = pushTargetPath(payload);
|
||||||
|
navigate(path.replace(/^\//, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeNotificationOpenFromUrlIfAny() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const rawPayload = String(params.get('pushOpenPayload') || '');
|
||||||
|
if (!rawPayload) return null;
|
||||||
|
let payload = {};
|
||||||
|
try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {}
|
||||||
|
params.delete('pushOpenPayload');
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`);
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processPendingCallPushActionIfPossible() {
|
async function processPendingCallPushActionIfPossible() {
|
||||||
if (!state.session.isAuthorized) return;
|
if (!state.session.isAuthorized) return;
|
||||||
const pending = loadPendingCallPushAction();
|
const pending = loadPendingCallPushAction();
|
||||||
@@ -1197,7 +1274,8 @@ function renderApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||||
|
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) {
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1286,6 +1364,11 @@ async function tryAutoLogin() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
|
const result = await closeSavedProfile(state.session.login);
|
||||||
|
if (result?.nextProfile) {
|
||||||
|
window.location.assign('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await terminateCurrentSession({
|
await terminateCurrentSession({
|
||||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||||
});
|
});
|
||||||
@@ -1338,6 +1421,7 @@ async function ensureSessionRuntimeStarted() {
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
consumeCallPushActionFromUrlIfAny();
|
consumeCallPushActionFromUrlIfAny();
|
||||||
|
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||||
void tryLockPortraitOrientation();
|
void tryLockPortraitOrientation();
|
||||||
|
|
||||||
if (state.session.isLocalDemo) {
|
if (state.session.isLocalDemo) {
|
||||||
@@ -1373,12 +1457,20 @@ async function init() {
|
|||||||
const action = String(data.action || '').trim().toLowerCase();
|
const action = String(data.action || '').trim().toLowerCase();
|
||||||
const payload = data.payload || {};
|
const payload = data.payload || {};
|
||||||
if (action === 'accept' || action === 'decline') {
|
if (action === 'accept' || action === 'decline') {
|
||||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
void (async () => {
|
||||||
savePendingCallPushAction(action, payload);
|
const canHandle = await ensurePushTargetProfile(payload, { action });
|
||||||
void processPendingCallPushActionIfPossible();
|
if (!canHandle) return;
|
||||||
|
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
await processPendingCallPushActionIfPossible();
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type === 'SHINE_NOTIFICATION_CLICK') {
|
||||||
|
void handleNotificationClick(data.payload || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||||
|
|
||||||
const payload = data.payload || {};
|
const payload = data.payload || {};
|
||||||
@@ -1411,7 +1503,8 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authService.onEvent('SessionRevoked', async () => {
|
authService.onEvent('SessionRevoked', async () => {
|
||||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
const result = await closeSavedProfile(state.session.login);
|
||||||
|
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||||
});
|
});
|
||||||
|
|
||||||
authService.onEvent('ForceUiReload', async (evt) => {
|
authService.onEvent('ForceUiReload', async (evt) => {
|
||||||
@@ -1714,6 +1807,9 @@ async function init() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await tryAutoLogin();
|
await tryAutoLogin();
|
||||||
|
if (initialNotificationOpenPayload) {
|
||||||
|
await handleNotificationClick(initialNotificationOpenPayload);
|
||||||
|
}
|
||||||
await hydrateMessagesFromStore();
|
await hydrateMessagesFromStore();
|
||||||
if (!state.session.isLocalDemo) {
|
if (!state.session.isLocalDemo) {
|
||||||
startConnectionMonitor();
|
startConnectionMonitor();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state, authService } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const isProfile = item.pageId === 'profile-view';
|
const isProfile = item.pageId === 'profile-view';
|
||||||
const isMessages = item.pageId === 'messages-list';
|
const isMessages = item.pageId === 'messages-list';
|
||||||
const isNetwork = item.pageId === 'network-view';
|
const isNetwork = item.pageId === 'network-view';
|
||||||
|
const isNotifications = item.pageId === 'notifications-view';
|
||||||
|
btn.dataset.toolbarPage = item.pageId;
|
||||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||||
if (isProfile) {
|
if (isProfile) {
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
@@ -97,6 +99,14 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||||
btn.append(badge);
|
btn.append(badge);
|
||||||
}
|
}
|
||||||
|
if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'toolbar-unread-badge notification-toolbar-badge';
|
||||||
|
const n = Number(state.notificationUnreadTotal || 0);
|
||||||
|
badge.textContent = n > 99 ? '99+' : String(n);
|
||||||
|
badge.setAttribute('aria-label', `Новых уведомлений: ${n}`);
|
||||||
|
btn.append(badge);
|
||||||
|
}
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
btn.addEventListener('click', () => navigate('channels-list'));
|
btn.addEventListener('click', () => navigate('channels-list'));
|
||||||
} else {
|
} else {
|
||||||
@@ -105,5 +115,19 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
root.append(btn);
|
root.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
|
||||||
|
void authService.getNotifications(true).then((payload) => {
|
||||||
|
const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0);
|
||||||
|
state.notificationUnreadTotal = total;
|
||||||
|
const btn = root.querySelector('[data-toolbar-page="notifications-view"]');
|
||||||
|
if (!btn) return;
|
||||||
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||||
|
if (total <= 0) { badge?.remove(); return; }
|
||||||
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||||
|
badge.textContent = total > 99 ? '99+' : String(total);
|
||||||
|
badge.setAttribute('aria-label', `Новых уведомлений: ${total}`);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -920,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
likeButton.innerHTML = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes} · ${primaryLikes} · ${shiningLikes}</span>
|
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||||
`;
|
`;
|
||||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
@@ -964,24 +964,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const ratingButton = document.createElement('button');
|
// Rating/opinion action is intentionally hidden from UI for now.
|
||||||
ratingButton.type = 'button';
|
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||||
ratingButton.className = 'channel-action-item thread-rating-btn';
|
|
||||||
ratingButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
|
||||||
<span class="channel-action-label">Оценка</span>
|
|
||||||
<span class="channel-action-counter">${ratings}</span>
|
|
||||||
`;
|
|
||||||
setActionTitle(ratingButton, 'Оценка');
|
|
||||||
ratingButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openReplyModal({
|
|
||||||
navigate: handlers.navigate,
|
|
||||||
mode: 'rating',
|
|
||||||
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -999,7 +983,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
// Репосты временно отключены до будущей реализации.
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
actions.append(likeButton, replyButton, shareButton);
|
||||||
if (repostTarget) {
|
if (repostTarget) {
|
||||||
const originalButton = document.createElement('button');
|
const originalButton = document.createElement('button');
|
||||||
originalButton.type = 'button';
|
originalButton.type = 'button';
|
||||||
|
|||||||
@@ -1963,7 +1963,7 @@ function renderPostCard(post, {
|
|||||||
likeButton.innerHTML = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0} · ${post.primaryLikesCount || 0} · ${post.shiningLikesCount || 0}</span>
|
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
|
||||||
`;
|
`;
|
||||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
@@ -1998,27 +1998,10 @@ function renderPostCard(post, {
|
|||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const ratingButton = document.createElement('button');
|
// Rating/opinion action is intentionally hidden from UI for now.
|
||||||
ratingButton.type = 'button';
|
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||||
ratingButton.className = 'channel-action-item channel-action-rating';
|
|
||||||
ratingButton.innerHTML = `
|
actions.append(likeButton, replyButton);
|
||||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
|
||||||
<span class="channel-action-label">Оценка</span>
|
|
||||||
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
|
||||||
`;
|
|
||||||
setActionTitle(ratingButton, 'Оценка');
|
|
||||||
ratingButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openReplyModal({
|
|
||||||
navigate,
|
|
||||||
mode: 'rating',
|
|
||||||
onSubmit: async (text) => onRating(post.messageRef, text),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
|
||||||
actions.append(likeButton, replyButton, ratingButton);
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -2283,9 +2266,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
|
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
header.classList.add('channel-view-topbar');
|
||||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
|
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2561,6 +2547,36 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (aboutRoute) navigate(aboutRoute);
|
if (aboutRoute) navigate(aboutRoute);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (channelMoreButton) {
|
||||||
|
channelMoreButton.disabled = false;
|
||||||
|
channelMoreButton.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
header.querySelector('.channel-header-more-menu')?.remove();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'channel-header-more-menu';
|
||||||
|
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
|
||||||
|
about.onclick = () => {
|
||||||
|
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
|
||||||
|
menu.remove(); if (aboutRoute) navigate(aboutRoute);
|
||||||
|
};
|
||||||
|
menu.append(about);
|
||||||
|
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
||||||
|
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
|
||||||
|
unfollow.onclick = async () => {
|
||||||
|
menu.remove();
|
||||||
|
try {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
|
||||||
|
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
|
||||||
|
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
|
||||||
|
};
|
||||||
|
menu.append(unfollow);
|
||||||
|
}
|
||||||
|
header.append(menu);
|
||||||
|
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||||
|
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ function cleanChannelMessagePreview(text) {
|
|||||||
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the channel-list preview tolerant to small API naming changes. The current
|
||||||
|
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
|
||||||
|
// so the third line (last message + time) does not silently disappear.
|
||||||
|
function resolveChannelLastMessage(summary) {
|
||||||
|
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
|
||||||
|
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
|
||||||
|
return {
|
||||||
|
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
|
||||||
|
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
@@ -719,6 +731,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const title = displayTitle || channelName;
|
const title = displayTitle || channelName;
|
||||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||||
|
const lastMessage = resolveChannelLastMessage(summary);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
@@ -737,10 +750,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
channelDescription,
|
channelDescription,
|
||||||
channelTypeCode,
|
channelTypeCode,
|
||||||
channelTypeVersion,
|
channelTypeVersion,
|
||||||
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
|
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||||
messagesCount: Number(summary?.messagesCount || 0),
|
messagesCount: Number(summary?.messagesCount || 0),
|
||||||
unreadCount: Number(summary?.unreadCount || 0),
|
unreadCount: Number(summary?.unreadCount || 0),
|
||||||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||||
isOwnChannel: isOwn,
|
isOwnChannel: isOwn,
|
||||||
isSubscribed: !isOwn,
|
isSubscribed: !isOwn,
|
||||||
notificationsEnabled: notificationsState[rowId] === true,
|
notificationsEnabled: notificationsState[rowId] === true,
|
||||||
|
|||||||
+164
-114
@@ -30,7 +30,6 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
|
|||||||
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';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { userDisplayName } from '../services/user-display.js';
|
|
||||||
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
@@ -59,11 +58,11 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
|||||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||||
<span>Связи</span>
|
<span>Показать связи</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||||
<span>Профиль</span>
|
<span>Показать профиль</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,70 +101,93 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChatRelationType(value) {
|
||||||
|
const clean = String(value || '').trim().toLowerCase();
|
||||||
|
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatRelationLabel(value) {
|
||||||
|
switch (normalizeChatRelationType(value)) {
|
||||||
|
case 'close_friend': return 'Близкий друг';
|
||||||
|
case 'friend': return 'Друг';
|
||||||
|
case 'contact': return 'Контакт';
|
||||||
|
default: return 'Не в контактах';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createChatHeaderParts(login, navigate) {
|
function createChatHeaderParts(login, navigate) {
|
||||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||||
|
let currentPeer = {
|
||||||
|
login: cleanLogin,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
avatar: null,
|
||||||
|
relationType: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityButton = document.createElement('button');
|
||||||
|
identityButton.type = 'button';
|
||||||
|
identityButton.className = 'chat-header-peer-btn';
|
||||||
|
identityButton.title = `Меню ${cleanLogin}`;
|
||||||
|
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||||
|
|
||||||
const avatarSlot = document.createElement('span');
|
const avatarSlot = document.createElement('span');
|
||||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
avatarSlot.className = 'chat-header-avatar-slot';
|
||||||
const initialAvatar = renderUserAvatar({
|
const textWrap = document.createElement('span');
|
||||||
login: cleanLogin,
|
textWrap.className = 'chat-header-peer-text';
|
||||||
size: 'md',
|
const nameEl = document.createElement('span');
|
||||||
className: 'chat-header-avatar',
|
nameEl.className = 'chat-header-peer-name';
|
||||||
title: cleanLogin,
|
const metaEl = document.createElement('span');
|
||||||
});
|
metaEl.className = 'chat-header-peer-meta';
|
||||||
avatarSlot.append(initialAvatar);
|
textWrap.append(nameEl, metaEl);
|
||||||
|
identityButton.append(avatarSlot, textWrap);
|
||||||
|
|
||||||
const avatarButton = document.createElement('button');
|
const renderPeer = () => {
|
||||||
avatarButton.type = 'button';
|
const firstName = String(currentPeer?.firstName || '').trim();
|
||||||
avatarButton.className = 'chat-header-avatar-btn';
|
const lastName = String(currentPeer?.lastName || '').trim();
|
||||||
avatarButton.title = `Меню ${cleanLogin}`;
|
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
nameEl.textContent = fullName || cleanLogin;
|
||||||
avatarButton.append(avatarSlot);
|
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||||
|
const avatar = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
avatar: currentPeer?.avatar?.ar
|
||||||
|
? {
|
||||||
|
ar: String(currentPeer.avatar.ar || '').trim(),
|
||||||
|
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'md',
|
||||||
|
className: 'chat-header-avatar',
|
||||||
|
title: cleanLogin,
|
||||||
|
});
|
||||||
|
avatarSlot.replaceChildren(avatar);
|
||||||
|
};
|
||||||
|
|
||||||
const loginEl = document.createElement('button');
|
const updatePeer = (peer) => {
|
||||||
loginEl.type = 'button';
|
if (!peer || typeof peer !== 'object') return;
|
||||||
loginEl.className = 'chat-header-login chat-header-login-btn';
|
currentPeer = {
|
||||||
loginEl.setAttribute('role', 'heading');
|
...currentPeer,
|
||||||
loginEl.setAttribute('aria-level', '1');
|
...peer,
|
||||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
login: String(peer.login || cleanLogin).trim() || cleanLogin,
|
||||||
loginEl.innerHTML = `<span class="chat-header-display-name">${cleanLogin}</span><span class="chat-header-user-login">${cleanLogin}</span>`;
|
relationType: normalizeChatRelationType(peer.relationType),
|
||||||
|
};
|
||||||
|
renderPeer();
|
||||||
|
};
|
||||||
|
|
||||||
void loadProfileSnapshot(cleanLogin)
|
renderPeer();
|
||||||
.then((snapshot) => {
|
identityButton.addEventListener('click', (event) => {
|
||||||
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: 'md',
|
|
||||||
className: 'chat-header-avatar',
|
|
||||||
title: cleanLogin,
|
|
||||||
});
|
|
||||||
avatarSlot.replaceChildren(upgradedAvatar);
|
|
||||||
if (loginEl.isConnected) {
|
|
||||||
const display = userDisplayName({ login: cleanLogin, firstName: snapshot?.firstName, lastName: snapshot?.lastName });
|
|
||||||
const nameNode = loginEl.querySelector('.chat-header-display-name');
|
|
||||||
const loginNode = loginEl.querySelector('.chat-header-user-login');
|
|
||||||
if (nameNode) nameNode.textContent = display;
|
|
||||||
if (loginNode) loginNode.textContent = cleanLogin;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
const openMenu = (event) => {
|
|
||||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||||
};
|
});
|
||||||
avatarButton.addEventListener('click', openMenu);
|
|
||||||
loginEl.addEventListener('click', openMenu);
|
|
||||||
|
|
||||||
return { centerNode: loginEl, avatarButton };
|
return {
|
||||||
|
centerNode: identityButton,
|
||||||
|
updatePeer,
|
||||||
|
getPeer: () => ({ ...currentPeer }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
@@ -242,18 +264,30 @@ function openChatConfirmModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
const relation = normalizeChatRelationType(relationType);
|
||||||
|
const isCloseFriend = relation === 'close_friend';
|
||||||
|
const isFriend = relation === 'friend';
|
||||||
|
const isProtectedRelation = isCloseFriend || isFriend;
|
||||||
|
const relationName = isCloseFriend ? 'близких друзей' : 'друзей';
|
||||||
|
const safeName = String(contactName || '').trim() || 'этого пользователя';
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="chat-delete-chat-modal">
|
<div class="modal" id="chat-delete-chat-modal">
|
||||||
<div class="modal-card stack dm-dialog-card">
|
<div class="modal-card stack dm-dialog-card">
|
||||||
<h3 class="modal-title">Удалить чат?</h3>
|
<h3 class="modal-title">Удалить чат?</h3>
|
||||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
${isProtectedRelation ? `
|
||||||
<label class="dm-confirm-check">
|
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
|
||||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
|
||||||
<span>Также удалить всю историю переписки</span>
|
` : `
|
||||||
</label>
|
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
|
||||||
|
<label class="dm-confirm-check">
|
||||||
|
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||||
|
<span>Также удалить всю историю переписки</span>
|
||||||
|
</label>
|
||||||
|
`}
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||||
@@ -268,10 +302,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
|||||||
|
|
||||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||||
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
||||||
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
const deleteHistory = isProtectedRelation
|
||||||
|
? true
|
||||||
|
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||||
close();
|
close();
|
||||||
if (typeof onConfirm === 'function') {
|
if (typeof onConfirm === 'function') {
|
||||||
await onConfirm({ deleteHistory });
|
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -381,8 +417,10 @@ function openMessageActionsMenu({
|
|||||||
function openChatActionsMenu({
|
function openChatActionsMenu({
|
||||||
anchorX = 0,
|
anchorX = 0,
|
||||||
anchorY = 0,
|
anchorY = 0,
|
||||||
|
showAddContact = false,
|
||||||
onCall,
|
onCall,
|
||||||
onVideoCall,
|
onVideoCall,
|
||||||
|
onAddContact,
|
||||||
onClearHistory,
|
onClearHistory,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) {
|
}) {
|
||||||
@@ -395,6 +433,7 @@ function openChatActionsMenu({
|
|||||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||||
|
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true">+</span><span>Добавить в контакты</span></button>` : ''}
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,6 +485,10 @@ function openChatActionsMenu({
|
|||||||
close();
|
close();
|
||||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||||
});
|
});
|
||||||
|
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
|
||||||
|
close();
|
||||||
|
if (typeof onAddContact === 'function') await onAddContact();
|
||||||
|
});
|
||||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||||
close();
|
close();
|
||||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||||
@@ -968,6 +1011,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack dm-screen dm-chat-screen';
|
screen.className = 'stack dm-screen dm-chat-screen';
|
||||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
|
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
||||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||||
let historyHasMore = true;
|
let historyHasMore = true;
|
||||||
@@ -1062,6 +1106,24 @@ export function render({ navigate, route, chrome }) {
|
|||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPeerToContacts = async () => {
|
||||||
|
const approved = await openConfirmContactModal(chatId);
|
||||||
|
if (!approved) return;
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: state.session.login,
|
||||||
|
toLogin: chatId,
|
||||||
|
kind: 'contact',
|
||||||
|
enabled: true,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
peerRelationType = 'contact';
|
||||||
|
chatHeaderParts?.updatePeer?.({ relationType: 'contact' });
|
||||||
|
const contactsPayload = await authService.listContacts();
|
||||||
|
setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []);
|
||||||
|
notifyUnreadStateUpdated();
|
||||||
|
showToast('Добавлено в контакты', { timeoutMs: 1200 });
|
||||||
|
};
|
||||||
|
|
||||||
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');
|
||||||
@@ -1098,8 +1160,16 @@ export function render({ navigate, route, chrome }) {
|
|||||||
openChatActionsMenu({
|
openChatActionsMenu({
|
||||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||||
|
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
|
||||||
onCall: () => handleStartCall('audio'),
|
onCall: () => handleStartCall('audio'),
|
||||||
onVideoCall: () => handleStartCall('video'),
|
onVideoCall: () => handleStartCall('video'),
|
||||||
|
onAddContact: async () => {
|
||||||
|
try {
|
||||||
|
await addPeerToContacts();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
|
}
|
||||||
|
},
|
||||||
onClearHistory: async () => {
|
onClearHistory: async () => {
|
||||||
openChatConfirmModal({
|
openChatConfirmModal({
|
||||||
title: 'Очистить историю?',
|
title: 'Очистить историю?',
|
||||||
@@ -1117,24 +1187,40 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDeleteChat: async () => {
|
onDeleteChat: async () => {
|
||||||
|
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||||
openDeleteChatConfirmModal({
|
openDeleteChatConfirmModal({
|
||||||
contactName: contact.name,
|
contactName: contact.name,
|
||||||
|
relationType: relationBeforeDelete,
|
||||||
onConfirm: async ({ deleteHistory }) => {
|
onConfirm: async ({ deleteHistory }) => {
|
||||||
try {
|
try {
|
||||||
if (deleteHistory) {
|
if (deleteHistory) {
|
||||||
await clearConversationHistory();
|
await clearConversationHistory();
|
||||||
}
|
}
|
||||||
await authService.setUserRelation({
|
|
||||||
login: state.session.login,
|
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
|
||||||
toLogin: chatId,
|
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
|
||||||
kind: 'contact',
|
// закономерно останется в списке из-за действующей связи.
|
||||||
enabled: false,
|
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
? ['close_friend', 'friend', 'contact']
|
||||||
});
|
: ['contact'];
|
||||||
|
for (const kind of relationKinds) {
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: state.session.login,
|
||||||
|
toLogin: chatId,
|
||||||
|
kind,
|
||||||
|
enabled: false,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const contactsPayload = await authService.listContacts();
|
const contactsPayload = await authService.listContacts();
|
||||||
setContacts(contactsPayload?.contacts || []);
|
setContacts(
|
||||||
|
contactsPayload?.contacts
|
||||||
|
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|
||||||
|
|| [],
|
||||||
|
);
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
showToast('Чат удалён', { timeoutMs: 1200 });
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
@@ -1147,48 +1233,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
|
||||||
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
|
||||||
chrome?.setTopbar(chatHeader);
|
chrome?.setTopbar(chatHeader);
|
||||||
|
|
||||||
if (!isKnownContact) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'secondary-btn';
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = 'Добавить собеседника в контакты';
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const approved = await openConfirmContactModal(chatId);
|
|
||||||
if (!approved) return;
|
|
||||||
await authService.setUserRelation({
|
|
||||||
login: state.session.login,
|
|
||||||
toLogin: chatId,
|
|
||||||
kind: 'contact',
|
|
||||||
enabled: true,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
const contactsPayload = await authService.listContacts();
|
|
||||||
setContacts(contactsPayload?.contacts || []);
|
|
||||||
addAppLogEntry({
|
|
||||||
level: 'info',
|
|
||||||
source: 'contacts',
|
|
||||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
|
||||||
});
|
|
||||||
card.remove();
|
|
||||||
} catch (e) {
|
|
||||||
addAppLogEntry({
|
|
||||||
level: 'warn',
|
|
||||||
source: 'contacts',
|
|
||||||
message: 'Не удалось добавить пользователя в контакты',
|
|
||||||
details: { login: chatId, error: e?.message || 'unknown' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
card.append(btn);
|
|
||||||
screen.append(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input dm-chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
@@ -1580,6 +1626,10 @@ export function render({ navigate, route, chrome }) {
|
|||||||
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
|
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
|
||||||
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
|
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
|
||||||
});
|
});
|
||||||
|
if (payload?.peer) {
|
||||||
|
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
|
||||||
|
chatHeaderParts.updatePeer(payload.peer);
|
||||||
|
}
|
||||||
await mergeDirectMessagesPage(chatId, payload?.messages || []);
|
await mergeDirectMessagesPage(chatId, payload?.messages || []);
|
||||||
historyHasMore = Boolean(payload?.hasMore);
|
historyHasMore = Boolean(payload?.hasMore);
|
||||||
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
|
consumeAuthReturnPage,
|
||||||
|
isAddingProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
clearBrowserClientData,
|
clearBrowserClientData,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@@ -205,10 +207,13 @@ export function render({ navigate }) {
|
|||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(session.login).catch(() => {});
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
@@ -221,7 +226,7 @@ export function render({ navigate }) {
|
|||||||
state.loginDraft.password = '';
|
state.loginDraft.password = '';
|
||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
|
consumeAuthReturnPage,
|
||||||
|
isAddingProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
clearBrowserClientData,
|
clearBrowserClientData,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@@ -182,10 +184,13 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(session.login).catch(() => {});
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
@@ -199,7 +204,7 @@ export function render({ navigate }) {
|
|||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
||||||
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
||||||
@@ -215,10 +220,13 @@ export function render({ navigate }) {
|
|||||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(login).catch(() => {});
|
||||||
await authService.persistSessionMaterial(login, sessionMaterial);
|
await authService.persistSessionMaterial(login, sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(login, sessionId);
|
const resumed = await authService.resumeSession(login, sessionId);
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
@@ -231,7 +239,7 @@ export function render({ navigate }) {
|
|||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
||||||
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const schedulePoll = () => {
|
const schedulePoll = () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
|
cancelAddProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
setAuthBusy,
|
setAuthBusy,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
@@ -155,7 +156,17 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
panel,
|
panel,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { renderUserAvatar } from '../components/avatar-image.js';
|
|||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
import { createShineConnectionsLogo } from '../components/shine-logo.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';
|
||||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||||
import { userDisplayName } from '../services/user-display.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const PREVIEW_MAX_LEN = 200;
|
const PREVIEW_MAX_LEN = 200;
|
||||||
@@ -22,14 +22,72 @@ const SVG_CHEVRON = `
|
|||||||
<path d="M9 6l6 6-6 6"></path>
|
<path d="M9 6l6 6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
|
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||||
|
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||||
|
const dmAvatarSnapshotCache = new Map();
|
||||||
|
const dmAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
const RELATION_ORDER = new Map([
|
const RELATION_ORDER = new Map([
|
||||||
['close_friend', 0],
|
['close_friend', 0],
|
||||||
['friend', 1],
|
['friend', 1],
|
||||||
['contact', 2],
|
['contact', 2],
|
||||||
['none', 99],
|
['none', 3],
|
||||||
]);
|
]);
|
||||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
|
||||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
async function loadDmAvatarSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key);
|
||||||
|
if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key);
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
dmAvatarSnapshotCache.set(key, snapshot || null);
|
||||||
|
dmAvatarPendingByLogin.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dmAvatarSnapshotCache.set(key, null);
|
||||||
|
dmAvatarPendingByLogin.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
dmAvatarPendingByLogin.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
|
const avatarEl = renderUserAvatar({
|
||||||
|
login: cleanLogin || 'unknown',
|
||||||
|
firstName: String(firstName || '').trim(),
|
||||||
|
lastName: String(lastName || '').trim(),
|
||||||
|
avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null,
|
||||||
|
size: 'lg',
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
});
|
||||||
|
if (!cleanLogin || avatar?.ar) return avatarEl;
|
||||||
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
|
if (!avatarEl.isConnected) return;
|
||||||
|
const upgraded = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'lg',
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
});
|
||||||
|
upgraded.classList.add('avatar');
|
||||||
|
avatarEl.replaceWith(upgraded);
|
||||||
|
});
|
||||||
|
return avatarEl;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelationFlag(value) {
|
function normalizeRelationFlag(value) {
|
||||||
const clean = String(value || '').trim().toLowerCase();
|
const clean = String(value || '').trim().toLowerCase();
|
||||||
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
||||||
@@ -288,13 +346,10 @@ function renderRow(item) {
|
|||||||
const relationBadge = relationFlag === 'none'
|
const relationBadge = relationFlag === 'none'
|
||||||
? 'не в контактах'
|
? 'не в контактах'
|
||||||
: relationLabel(relationFlag);
|
: relationLabel(relationFlag);
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = createDmAvatar(item.peerLogin, {
|
||||||
login: item.peerLogin,
|
avatar: item.avatar,
|
||||||
firstName: item.firstName,
|
firstName: item.firstName,
|
||||||
lastName: item.lastName,
|
lastName: item.lastName,
|
||||||
avatar: item.avatarAr ? { ar: String(item.avatarAr).trim() } : null,
|
|
||||||
size: 'lg',
|
|
||||||
title: `Профиль ${item.peerLogin}`,
|
|
||||||
});
|
});
|
||||||
avatarEl.classList.add('avatar');
|
avatarEl.classList.add('avatar');
|
||||||
const avatarWrap = document.createElement('div');
|
const avatarWrap = document.createElement('div');
|
||||||
@@ -319,7 +374,10 @@ function renderRow(item) {
|
|||||||
const titleEl = row.querySelector('.dm-row-title');
|
const titleEl = row.querySelector('.dm-row-title');
|
||||||
const previewEl = row.querySelector('.dm-row-last-message');
|
const previewEl = row.querySelector('.dm-row-last-message');
|
||||||
const timeEl = row.querySelector('.dm-row-time');
|
const timeEl = row.querySelector('.dm-row-time');
|
||||||
if (titleEl) titleEl.textContent = userDisplayName(item);
|
if (titleEl) {
|
||||||
|
const fullName = [String(item.firstName || '').trim(), String(item.lastName || '').trim()].filter(Boolean).join(' ');
|
||||||
|
titleEl.textContent = fullName || String(item.peerLogin || '');
|
||||||
|
}
|
||||||
if (previewEl) previewEl.textContent = 'Загрузка…';
|
if (previewEl) previewEl.textContent = 'Загрузка…';
|
||||||
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
||||||
row.prepend(avatarWrap);
|
row.prepend(avatarWrap);
|
||||||
@@ -343,7 +401,10 @@ function renderRow(item) {
|
|||||||
try {
|
try {
|
||||||
const payload = await authService.listContacts();
|
const payload = await authService.listContacts();
|
||||||
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
||||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
const contacts = dialogs
|
||||||
|
.filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none')
|
||||||
|
.map((dialog) => String(dialog?.peerLogin || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
setContacts(contacts);
|
setContacts(contacts);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
@@ -356,12 +417,12 @@ function renderRow(item) {
|
|||||||
const next = {
|
const next = {
|
||||||
id: peerLogin,
|
id: peerLogin,
|
||||||
peerLogin,
|
peerLogin,
|
||||||
|
relationFlag,
|
||||||
firstName: String(dialog?.firstName || '').trim(),
|
firstName: String(dialog?.firstName || '').trim(),
|
||||||
lastName: String(dialog?.lastName || '').trim(),
|
lastName: String(dialog?.lastName || '').trim(),
|
||||||
avatarAr: String(dialog?.avatarAr || '').trim(),
|
avatar: dialog?.avatar && typeof dialog.avatar === 'object' ? dialog.avatar : null,
|
||||||
accountRole: String(dialog?.accountRole || '').trim(),
|
accountRole: String(dialog?.accountRole || '').trim(),
|
||||||
shineStatus: String(dialog?.shineStatus || '').trim(),
|
shineStatus: String(dialog?.shineStatus || '').trim(),
|
||||||
relationFlag,
|
|
||||||
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
||||||
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
||||||
unreadCount: Number(dialog?.unreadCount || 0),
|
unreadCount: Number(dialog?.unreadCount || 0),
|
||||||
@@ -387,6 +448,9 @@ function renderRow(item) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = Array.from(byPeer.values())
|
const rows = Array.from(byPeer.values())
|
||||||
|
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
|
||||||
|
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
|
||||||
|
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
|
||||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const orderA = relationOrder(a.relationFlag);
|
const orderA = relationOrder(a.relationFlag);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
@@ -25,32 +27,6 @@ function createDebounced(fn, delayMs = 2000) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHeaderSearchIcon() {
|
|
||||||
const ns = 'http://www.w3.org/2000/svg';
|
|
||||||
const svg = document.createElementNS(ns, 'svg');
|
|
||||||
svg.setAttribute('viewBox', '0 0 24 24');
|
|
||||||
svg.setAttribute('aria-hidden', 'true');
|
|
||||||
svg.setAttribute('class', 'header-icon-svg header-icon-svg--search');
|
|
||||||
|
|
||||||
const circle = document.createElementNS(ns, 'circle');
|
|
||||||
circle.setAttribute('cx', '11');
|
|
||||||
circle.setAttribute('cy', '11');
|
|
||||||
circle.setAttribute('r', '6.5');
|
|
||||||
circle.setAttribute('fill', 'none');
|
|
||||||
circle.setAttribute('stroke', 'currentColor');
|
|
||||||
circle.setAttribute('stroke-width', '2');
|
|
||||||
|
|
||||||
const handle = document.createElementNS(ns, 'path');
|
|
||||||
handle.setAttribute('d', 'M16 16l4.5 4.5');
|
|
||||||
handle.setAttribute('fill', 'none');
|
|
||||||
handle.setAttribute('stroke', 'currentColor');
|
|
||||||
handle.setAttribute('stroke-width', '2');
|
|
||||||
handle.setAttribute('stroke-linecap', 'round');
|
|
||||||
|
|
||||||
svg.append(circle, handle);
|
|
||||||
return svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normKey(value) {
|
function normKey(value) {
|
||||||
return normalizeLogin(value).toLowerCase();
|
return normalizeLogin(value).toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -290,7 +266,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
<div class="modal" id="network-search-modal">
|
<div class="modal" id="network-search-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
||||||
<h3 class="modal-title">Найти человека</h3>
|
<h3 class="modal-title">Найти пользователя</h3>
|
||||||
<div class="row" style="gap:8px;">
|
<div class="row" style="gap:8px;">
|
||||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||||
@@ -462,17 +438,32 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{
|
{
|
||||||
iconNode: createHeaderSearchIcon(),
|
iconNode: createOverflowDots(),
|
||||||
title: 'Найти пользователя',
|
title: 'Меню связей',
|
||||||
ariaLabel: 'Найти пользователя',
|
ariaLabel: 'Открыть меню связей',
|
||||||
className: 'chat-header-icon-btn',
|
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||||
onClick: openSearchModal,
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const networkMenuButton = header.querySelector('.network-header-menu-btn');
|
||||||
|
const searchIconHtml = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
|
<path d="M16 16l4 4"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const networkMenu = createDropdownMenu({
|
||||||
|
anchorEl: networkMenuButton,
|
||||||
|
minWidth: 220,
|
||||||
|
items: [
|
||||||
|
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
networkMenu.destroy();
|
||||||
if (engine) engine.destroy();
|
if (engine) engine.destroy();
|
||||||
engine = null;
|
engine = null;
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|||||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
const CONNECTION_CLOSE_FRIEND = 10;
|
const CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
const CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
|
const CONNECTION_FRIEND = 14;
|
||||||
|
const CONNECTION_UNFRIEND = 15;
|
||||||
|
const CONNECTION_FOLLOW = 30;
|
||||||
|
const CONNECTION_UNFOLLOW = 31;
|
||||||
|
const CONNECTION_SHINE_CONFIRMED = 70;
|
||||||
|
const CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||||
|
const CONNECTION_OFFICIAL_CONFIRMED = 80;
|
||||||
|
const CONNECTION_OFFICIAL_UNCONFIRMED = 81;
|
||||||
const profileSnapshotCache = new Map();
|
const profileSnapshotCache = new Map();
|
||||||
const profileSnapshotPending = new Map();
|
const profileSnapshotPending = new Map();
|
||||||
|
|
||||||
function connectionTypeLabel(typeCode) {
|
function connectionActionLabel(typeCode) {
|
||||||
switch (Number(typeCode)) {
|
switch (Number(typeCode)) {
|
||||||
case CONNECTION_CLOSE_FRIEND:
|
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
||||||
return 'близкие друзья';
|
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
||||||
default:
|
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
|
||||||
return 'новую связь';
|
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
|
||||||
|
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
|
||||||
|
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
|
||||||
|
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
|
||||||
|
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
|
||||||
|
default: return 'Изменил(а) связь с вами.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventActionLabel(typeCode) {
|
||||||
|
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
|
||||||
|
return 'Подписался(-ась) на ваш канал.';
|
||||||
|
}
|
||||||
|
|
||||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||||
|
|
||||||
function normalizeItem(item) {
|
function normalizeItem(item) {
|
||||||
@@ -136,12 +155,10 @@ function renderEmpty(activeTab) {
|
|||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack notification-empty-state';
|
card.className = 'card stack notification-empty-state';
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
text.textContent = activeTab === 'events'
|
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
|
||||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
|
||||||
card.append(title, text);
|
card.append(title, text);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -239,7 +256,7 @@ function renderEngagement(engagement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notificationRoute(item, activeTab) {
|
function notificationRoute(item, activeTab) {
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'events' || activeTab === 'connections') {
|
||||||
const login = String(item?.sourceLogin || '').trim();
|
const login = String(item?.sourceLogin || '').trim();
|
||||||
return login ? makeProfileRoute(login) : '';
|
return login ? makeProfileRoute(login) : '';
|
||||||
}
|
}
|
||||||
@@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) {
|
|||||||
|
|
||||||
const action = document.createElement('p');
|
const action = document.createElement('p');
|
||||||
action.className = 'notification-action';
|
action.className = 'notification-action';
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'connections') {
|
||||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||||
|
} else if (activeTab === 'events') {
|
||||||
|
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||||
} else {
|
} else {
|
||||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||||
}
|
}
|
||||||
@@ -305,90 +324,98 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
tabs.innerHTML = `
|
const tabDefs = [['replies','Ответы'],['connections','Связи'],['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';
|
||||||
|
let payloadCache = null;
|
||||||
let requestSeq = 0;
|
let requestSeq = 0;
|
||||||
|
let observer = null;
|
||||||
|
const pendingSeenTimers = { replies: null, connections: null, events: null };
|
||||||
|
const localSeen = { replies: 0, connections: 0, events: 0 };
|
||||||
|
|
||||||
async function load() {
|
function countsFromPayload(payload) {
|
||||||
const seq = ++requestSeq;
|
return {
|
||||||
const activeTab = state.notificationsTab;
|
replies: Number(payload?.repliesUnseenCount || 0),
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
connections: Number(payload?.connectionsUnseenCount || 0),
|
||||||
|
events: Number(payload?.eventsUnseenCount || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function updateToolbarBadge(payload) {
|
||||||
const payload = await authService.getNotifications(50);
|
const c = countsFromPayload(payload);
|
||||||
if (seq !== requestSeq) return;
|
state.notificationUnreadTotal = c.replies + c.connections + c.events;
|
||||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
const btn = document.querySelector('[data-toolbar-page="notifications-view"]');
|
||||||
.map(normalizeItem);
|
if (!btn) return;
|
||||||
if (!baseItems.length) {
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
||||||
return;
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||||
}
|
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
||||||
|
}
|
||||||
|
|
||||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
function renderTabs(payload) {
|
||||||
if (seq !== requestSeq) return;
|
const counts = countsFromPayload(payload);
|
||||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||||
} catch (error) {
|
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
||||||
if (seq !== requestSeq) return;
|
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||||
const card = document.createElement('article');
|
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||||
card.className = 'card stack';
|
return b;
|
||||||
const title = document.createElement('strong');
|
}));
|
||||||
title.textContent = 'Не удалось загрузить уведомления';
|
}
|
||||||
const text = document.createElement('p');
|
|
||||||
text.className = 'meta-muted';
|
function categoryData(payload, tab) {
|
||||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) };
|
||||||
card.append(title, text);
|
if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) };
|
||||||
list.replaceChildren(card);
|
return { items: payload?.replies || [], seenAt: Number(payload?.repliesSeenAtMs || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSeen(category, seenAtMs) {
|
||||||
|
if (seenAtMs <= Number(localSeen[category] || 0)) return;
|
||||||
|
localSeen[category] = seenAtMs;
|
||||||
|
clearTimeout(pendingSeenTimers[category]);
|
||||||
|
pendingSeenTimers[category] = setTimeout(async () => {
|
||||||
|
const target = Number(localSeen[category] || 0);
|
||||||
|
try {
|
||||||
|
await authService.setNotificationSeen({ login: state.session.login, category, seenAtMs: target, storagePwd: state.session.storagePwdInMemory });
|
||||||
|
if (!payloadCache) return;
|
||||||
|
const key = category === 'connections' ? 'connectionsSeenAtMs' : category === 'events' ? 'eventsSeenAtMs' : 'repliesSeenAtMs';
|
||||||
|
const countKey = category === 'connections' ? 'connectionsUnseenCount' : category === 'events' ? 'eventsUnseenCount' : 'repliesUnseenCount';
|
||||||
|
payloadCache[key] = Math.max(Number(payloadCache[key] || 0), target);
|
||||||
|
payloadCache[countKey] = (payloadCache[category] || []).filter(x => Number(x?.createdAtMs || 0) > payloadCache[key]).length;
|
||||||
|
renderTabs(payloadCache); updateToolbarBadge(payloadCache);
|
||||||
|
} catch (e) { console.warn('Не удалось подписать watermark уведомлений', e); }
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrent() {
|
||||||
|
observer?.disconnect(); observer=null; renderTabs(payloadCache || {});
|
||||||
|
const tab=state.notificationsTab; const {items:raw,seenAt}=categoryData(payloadCache || {},tab); localSeen[tab]=Math.max(localSeen[tab]||0,seenAt);
|
||||||
|
const base=raw.map(normalizeItem); if(!base.length){list.replaceChildren(renderEmpty(tab));return;}
|
||||||
|
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
|
||||||
|
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
|
||||||
|
const nodes=[];
|
||||||
|
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
|
||||||
|
let divider=null;
|
||||||
|
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
|
||||||
|
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
|
||||||
|
if(unread.length && 'IntersectionObserver' in window){
|
||||||
|
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
|
||||||
|
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
|
||||||
|
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveNotificationTab(nextTab) {
|
async function load() {
|
||||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab));
|
||||||
state.notificationsTab = normalizedTab;
|
try { payloadCache=await authService.getNotifications(); if(seq!==requestSeq)return; updateToolbarBadge(payloadCache); await renderCurrent(); }
|
||||||
|
catch(error){ if(seq!==requestSeq)return; const card=document.createElement('article');card.className='card stack';card.innerHTML='<strong>Не удалось загрузить уведомления</strong>';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);}
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies';
|
||||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
screen.cleanup = () => {
|
||||||
setActiveNotificationTab(state.notificationsTab);
|
observer?.disconnect();
|
||||||
|
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
};
|
||||||
btn.addEventListener('click', () => {
|
screen.append(tabs,list);
|
||||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
|
||||||
if (state.notificationsTab === nextTab) {
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.append(tabs, list);
|
|
||||||
void load();
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function render({ navigate, chrome }) {
|
|||||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||||
|
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
chrome?.setTopbar(topbar);
|
chrome?.setTopbar(topbar);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import {
|
||||||
|
closeAllSavedProfiles,
|
||||||
|
closeSavedProfile,
|
||||||
|
getSavedProfiles,
|
||||||
|
prepareAddProfileLogin,
|
||||||
|
state,
|
||||||
|
switchToSavedProfile,
|
||||||
|
} from '../state.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'profiles-view', title: 'Профили' };
|
||||||
|
|
||||||
|
function reloadTo(path) {
|
||||||
|
const clean = String(path || '/profile').trim() || '/profile';
|
||||||
|
window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack profiles-screen';
|
||||||
|
|
||||||
|
screen.append(renderHeader({
|
||||||
|
title: 'Профили',
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const intro = document.createElement('div');
|
||||||
|
intro.className = 'meta-muted profiles-summary';
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'stack profiles-list';
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.hidden = true;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack profiles-actions';
|
||||||
|
|
||||||
|
const addButton = document.createElement('button');
|
||||||
|
addButton.type = 'button';
|
||||||
|
addButton.className = 'secondary-btn';
|
||||||
|
addButton.textContent = 'Добавить профиль';
|
||||||
|
addButton.addEventListener('click', async () => {
|
||||||
|
addButton.disabled = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Подготавливаем вход в новый профиль…';
|
||||||
|
try {
|
||||||
|
await prepareAddProfileLogin();
|
||||||
|
navigate('login-view');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось начать добавление профиля: ${error?.message || 'unknown'}`;
|
||||||
|
addButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeAllButton = document.createElement('button');
|
||||||
|
closeAllButton.type = 'button';
|
||||||
|
closeAllButton.className = 'secondary-btn profiles-close-all';
|
||||||
|
closeAllButton.textContent = 'Закрыть все профили';
|
||||||
|
closeAllButton.addEventListener('click', async () => {
|
||||||
|
const profiles = getSavedProfiles();
|
||||||
|
if (!profiles.length) return;
|
||||||
|
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
|
||||||
|
if (!confirmed) return;
|
||||||
|
closeAllButton.disabled = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = 'Закрываем профили…';
|
||||||
|
try {
|
||||||
|
await closeAllSavedProfiles();
|
||||||
|
reloadTo('/start');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось закрыть профили: ${error?.message || 'unknown'}`;
|
||||||
|
closeAllButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(addButton, closeAllButton);
|
||||||
|
screen.append(intro, list, status, actions);
|
||||||
|
|
||||||
|
const renderList = () => {
|
||||||
|
const profiles = getSavedProfiles();
|
||||||
|
const active = profiles.find((item) => item.isActive);
|
||||||
|
intro.textContent = profiles.length
|
||||||
|
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
|
||||||
|
: 'На устройстве нет сохранённых профилей.';
|
||||||
|
closeAllButton.disabled = profiles.length === 0;
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
profiles.forEach((profile) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = `card profiles-row${profile.isActive ? ' is-active' : ''}`;
|
||||||
|
|
||||||
|
const select = document.createElement('button');
|
||||||
|
select.type = 'button';
|
||||||
|
select.className = 'profiles-select';
|
||||||
|
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||||
|
select.disabled = profile.isActive;
|
||||||
|
select.addEventListener('click', async () => {
|
||||||
|
if (profile.isActive) return;
|
||||||
|
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = `Подключаем профиль ${profile.login}…`;
|
||||||
|
try {
|
||||||
|
await switchToSavedProfile(profile.login);
|
||||||
|
reloadTo('/profile');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось переключить профиль: ${error?.message || 'unknown'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.className = 'profiles-close';
|
||||||
|
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
|
||||||
|
close.textContent = '×';
|
||||||
|
close.addEventListener('click', async () => {
|
||||||
|
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
|
||||||
|
const message = profile.isActive
|
||||||
|
? (others.length
|
||||||
|
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
|
||||||
|
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
|
||||||
|
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
|
||||||
|
if (!window.confirm(message)) return;
|
||||||
|
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = `Закрываем профиль ${profile.login}…`;
|
||||||
|
try {
|
||||||
|
const result = await closeSavedProfile(profile.login);
|
||||||
|
if (profile.isActive) {
|
||||||
|
reloadTo(result.nextProfile ? '/profile' : '/start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.hidden = true;
|
||||||
|
renderList();
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось закрыть профиль: ${error?.message || 'unknown'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(select, close);
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
|
cancelAddProfileLogin,
|
||||||
|
consumeAuthReturnPage,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
resetRegistrationFlow,
|
resetRegistrationFlow,
|
||||||
@@ -103,7 +105,12 @@ export function render({ navigate }) {
|
|||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => {
|
cancelButton.addEventListener('click', async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetRegistrationFlow();
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
@@ -143,7 +150,7 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||||
|
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
login: state.registrationDraft.login,
|
login: state.registrationDraft.login,
|
||||||
@@ -174,13 +181,7 @@ export function render({ navigate }) {
|
|||||||
setAuthInfo(isLoginFlow
|
setAuthInfo(isLoginFlow
|
||||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||||
const nextHash = String(state.authReturnHash || '').trim();
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
state.authReturnHash = '';
|
|
||||||
if (nextHash.startsWith('/')) {
|
|
||||||
navigate(nextHash.slice(1));
|
|
||||||
} else {
|
|
||||||
navigate('profile-view');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
@@ -196,7 +197,12 @@ export function render({ navigate }) {
|
|||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: {
|
leftAction: {
|
||||||
label: '←',
|
label: '←',
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetRegistrationFlow();
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
|
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||||
|
|
||||||
const resumed = await authService.resumeSession(result.login, result.sessionId);
|
const resumed = await authService.resumeSession(result.login, result.sessionId);
|
||||||
const resumedLogin = resumed.login || result.login;
|
const resumedLogin = resumed.login || result.login;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
|
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function render({ navigate }) {
|
|||||||
const signOutBtn = card.querySelector('#settings-signout');
|
const signOutBtn = card.querySelector('#settings-signout');
|
||||||
signOutBtn.addEventListener('click', async () => {
|
signOutBtn.addEventListener('click', async () => {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
|
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -88,9 +88,8 @@ export function render({ navigate }) {
|
|||||||
source: 'session',
|
source: 'session',
|
||||||
message: 'Запрошено завершение текущей сессии',
|
message: 'Запрошено завершение текущей сессии',
|
||||||
});
|
});
|
||||||
await closeCurrentSessionAndSignOut({
|
const result = await closeSavedProfile(state.session.login);
|
||||||
infoMessage: 'Сеанс завершён. Выполните вход заново.',
|
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
signOutBtn.disabled = false;
|
signOutBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ function parseAvatar(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили основной аккаунт',
|
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт',
|
||||||
primary_given: 'Подтверждённые аккаунты', shine_received: 'Считают сияющим', shine_given: 'Подтверждённые сияющие',
|
primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
|
||||||
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
|
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,347 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
|
||||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { authService, state } from '../state.js';
|
||||||
|
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'user', title: 'Профиль пользователя' };
|
export const pageMeta = { id: 'user', title: 'Профиль' };
|
||||||
|
|
||||||
function escapeHtml(text){return String(text||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');}
|
function escapeHtml(text) {
|
||||||
function fullName(card){return [card.firstName,card.lastName].filter(Boolean).join(' ')||card.login;}
|
return String(text || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
function openTextModal(title,text){
|
.replaceAll('<', '<')
|
||||||
const root=document.getElementById('modal-root'); if(!root)return;
|
.replaceAll('>', '>')
|
||||||
root.innerHTML=`<div class="modal" id="profile-text-modal"><div class="modal-card stack"><h3>${escapeHtml(title)}</h3><div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text||'Не заполнено')}</div><button class="secondary-btn" id="profile-text-close">Закрыть</button></div></div>`;
|
.replaceAll('"', '"')
|
||||||
const close=()=>{root.innerHTML='';}; root.querySelector('#profile-text-close')?.addEventListener('click',close); root.querySelector('#profile-text-modal')?.addEventListener('click',e=>{if(e.target?.id==='profile-text-modal')close();});
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadges(card){
|
function effectiveSocial(flags = {}) {
|
||||||
const role=card.accountRole==='primary'?'Основной аккаунт':card.accountRole==='non_voting'?'Голос не учитывать':'';
|
if (flags.outCloseFriend) return 'close_friend';
|
||||||
const shine=card.shineStatus==='shining'?'Сияющий':'';
|
if (flags.outFriend) return 'friend';
|
||||||
return `<div class="row wrap-row">${role?`<span class="badge">${escapeHtml(role)}</span>`:''}${shine?'<span class="badge is-yes-shine">Сияющий</span>':''}${card.shineStatus==='not_interested'?'<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>':''}</div>`;
|
if (flags.outContact) return 'contact';
|
||||||
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statsHtml(card){
|
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||||
const s=card.stats||{};
|
const numericValue = Number(value || 0);
|
||||||
const rows=[
|
return `
|
||||||
['friends','Друзья',s.friendsCount],['close_friends','Близкие друзья',s.closeFriendsCount],
|
<button
|
||||||
['primary_received','Подтвердили основной аккаунт',s.primaryReceivedCount],['primary_given','Подтверждённые аккаунты',s.primaryGivenCount],
|
type="button"
|
||||||
['shine_received','Считают сияющим',s.shineReceivedCount],['shine_given','Подтверждённые сияющие',s.shineGivenCount],
|
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||||
['channels_following','Подписки на каналы',s.followingChannelsCount],['channels_owned','Каналы',s.ownedPublicChannelsCount],
|
data-profile-list="${escapeHtml(kind)}"
|
||||||
];
|
aria-label="${escapeHtml(label)}: ${numericValue}"
|
||||||
return `<div class="profile-stats-grid">${rows.map(([kind,label,n])=>`<button type="button" class="card profile-stat-card" data-profile-list="${kind}"><b>${Number(n||0)}</b><span>${escapeHtml(label)}</span></button>`).join('')}</div>`;
|
>
|
||||||
|
<span class="user-profile-metric-circle">${numericValue}</span>
|
||||||
|
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({navigate,route}){
|
function spiritualPathDetailHtml(card) {
|
||||||
const requestedLogin=String(route?.params?.login||'').trim(); const selfLogin=String(state.session.login||'').trim();
|
const value = String(card?.spiritualPath || '').trim();
|
||||||
const screen=document.createElement('section'); screen.className='stack'; const body=document.createElement('div');body.className='stack';const status=document.createElement('div');status.className='status-line';status.textContent='Загрузка профиля...';
|
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||||
screen.append(renderHeader({title:'Профиль пользователя',leftAction:{label:'←',onClick:()=>navigateBack()}}),status,body);
|
}
|
||||||
let card=null;
|
|
||||||
async function refresh(){
|
function contactsDetailHtml(card) {
|
||||||
card=await loadUserProfileCard(requestedLogin); const isSelf=card.login.toLowerCase()===selfLogin.toLowerCase();
|
const rows = [
|
||||||
body.innerHTML=`${statusBadges(card)}<div class="card profile-about" style="white-space:pre-wrap">${escapeHtml(card.about||'')}</div>${statsHtml(card)}
|
['Ссылки', card?.web],
|
||||||
<div class="row wrap-row"><button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button></div>
|
['Телефон', card?.phone],
|
||||||
${!isSelf?`<div class="row profile-bottom-actions"><button class="icon-btn" data-bottom="chat" title="Диалог">💬</button><button class="icon-btn" data-bottom="links" title="Связи">✦</button><button class="primary-btn" data-bottom="add">Добавить</button></div>`:''}`;
|
['Адрес', card?.address],
|
||||||
const identity=document.createElement('div');identity.className='card row';identity.style.gap='12px';identity.style.alignItems='center';
|
].filter(([, value]) => String(value || '').trim());
|
||||||
identity.append(renderUserAvatar({login:card.login,firstName:card.firstName,lastName:card.lastName,avatar:card.avatar,size:'xl',className:'profile-avatar'}));
|
|
||||||
const txt=document.createElement('div');txt.innerHTML=`<div class="profile-identity-line">${escapeHtml(fullName(card))}</div><div class="profile-identity-login">${escapeHtml(card.login)}</div>`;identity.append(txt);body.prepend(identity);status.textContent='';
|
if (!rows.length) {
|
||||||
}
|
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||||
body.addEventListener('click',e=>{const el=e.target.closest('[data-profile-list],[data-profile-detail],[data-bottom]');if(!el||!card)return;
|
}
|
||||||
const list=el.dataset.profileList;if(list){navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(list)}`);return;}
|
return rows.map(([label, value]) => `
|
||||||
const detail=el.dataset.profileDetail;if(detail==='contacts'){openTextModal('Контакты',[card.web?`Links: ${card.web}`:'',card.phone?`Телефон: ${card.phone}`:'',card.address?`Адрес: ${card.address}`:''].filter(Boolean).join('\n')||'Не заполнено');return;} if(detail==='spiritual'){openTextModal('Духовный путь',card.spiritualPath);return;}
|
<div class="user-profile-contact-row">
|
||||||
const bottom=el.dataset.bottom;if(bottom==='chat')navigate(`chat/${encodeURIComponent(card.login)}`);if(bottom==='links')navigate(makeProfileLinksRoute(card.login));if(bottom==='add')navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
|
<span>${escapeHtml(label)}</span>
|
||||||
});
|
<b>${escapeHtml(value)}</b>
|
||||||
refresh().catch(e=>{status.className='status-line is-unavailable';status.textContent=`Ошибка: ${e.message||'unknown'}`;}); return screen;
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIconHtml() {
|
||||||
|
return `
|
||||||
|
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||||
|
<path d="M10.5 20.5 17 27l13-14" />
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationMenuHtml(flags = {}) {
|
||||||
|
const current = effectiveSocial(flags);
|
||||||
|
const rows = [
|
||||||
|
['friend', 'Друг'],
|
||||||
|
['close_friend', 'Близкий друг'],
|
||||||
|
['contact', 'Контакт'],
|
||||||
|
];
|
||||||
|
return rows.map(([kind, label]) => `
|
||||||
|
<button type="button" class="user-profile-add-option${current === kind ? ' is-current' : ''}" data-relation-kind="${kind}">
|
||||||
|
<span>${escapeHtml(label)}</span>
|
||||||
|
<span class="user-profile-add-option-check" aria-hidden="true">${current === kind ? '✓' : ''}</span>
|
||||||
|
</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate, route, chrome }) {
|
||||||
|
const requestedLogin = String(route?.params?.login || '').trim();
|
||||||
|
const selfLogin = String(state.session.login || '').trim();
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack user-profile-screen';
|
||||||
|
|
||||||
|
const header = renderHeader({
|
||||||
|
title: requestedLogin || 'Профиль',
|
||||||
|
leftAction: { label: '←', onClick: () => navigateBack() },
|
||||||
|
});
|
||||||
|
header.classList.add('user-profile-header');
|
||||||
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'status-line user-profile-status';
|
||||||
|
status.textContent = 'Загрузка профиля...';
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'user-profile-body';
|
||||||
|
screen.append(status, body);
|
||||||
|
|
||||||
|
let card = null;
|
||||||
|
let relationFlags = null;
|
||||||
|
let relationLoadPromise = null;
|
||||||
|
let addMenu = null;
|
||||||
|
let addActionButton = null;
|
||||||
|
|
||||||
|
function updateRelationUi() {
|
||||||
|
if (!addMenu) return;
|
||||||
|
addMenu.innerHTML = relationMenuHtml(relationFlags || {});
|
||||||
|
const social = effectiveSocial(relationFlags || {});
|
||||||
|
addActionButton?.classList.toggle('is-active', social !== 'none');
|
||||||
|
addActionButton?.setAttribute('aria-label', social === 'none' ? 'Добавить' : 'Изменить связь');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRelationFlags() {
|
||||||
|
if (relationFlags) return relationFlags;
|
||||||
|
if (relationLoadPromise) return relationLoadPromise;
|
||||||
|
if (!selfLogin || !card?.login) return {};
|
||||||
|
relationLoadPromise = loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login })
|
||||||
|
.then((flags) => {
|
||||||
|
relationFlags = flags || {};
|
||||||
|
updateRelationUi();
|
||||||
|
return relationFlags;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
relationLoadPromise = null;
|
||||||
|
});
|
||||||
|
return relationLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRelationKind(kind, enabled) {
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: selfLogin,
|
||||||
|
toLogin: card.login,
|
||||||
|
kind,
|
||||||
|
enabled,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeSocial(next) {
|
||||||
|
const flags = await ensureRelationFlags();
|
||||||
|
const current = effectiveSocial(flags);
|
||||||
|
if (current === next) return;
|
||||||
|
|
||||||
|
if (next === 'contact') {
|
||||||
|
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
|
||||||
|
if (flags.outFriend) await setRelationKind('friend', false);
|
||||||
|
if (!flags.outContact) await setRelationKind('contact', true);
|
||||||
|
}
|
||||||
|
if (next === 'friend') {
|
||||||
|
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
|
||||||
|
if (!flags.outFriend) await setRelationKind('friend', true);
|
||||||
|
}
|
||||||
|
if (next === 'close_friend' && !flags.outCloseFriend) {
|
||||||
|
await setRelationKind('close_friend', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
relationFlags = await loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login });
|
||||||
|
updateRelationUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile() {
|
||||||
|
if (!card) return;
|
||||||
|
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
|
||||||
|
const stats = card.stats || {};
|
||||||
|
const official = card.accountRole === 'primary';
|
||||||
|
const shining = card.shineStatus === 'shining';
|
||||||
|
const fullName = [card.firstName, card.lastName]
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const about = String(card.about || '').trim();
|
||||||
|
|
||||||
|
const title = header.querySelector('.page-title');
|
||||||
|
if (title) title.textContent = card.login;
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
|
||||||
|
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
|
||||||
|
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||||
|
<div class="user-profile-avatar-slot"></div>
|
||||||
|
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
|
||||||
|
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="user-profile-channel-metrics">
|
||||||
|
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
|
||||||
|
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!isSelf ? `
|
||||||
|
<div class="user-profile-actions-wrap">
|
||||||
|
<div class="user-profile-add-menu" hidden></div>
|
||||||
|
<div class="user-profile-actions" aria-label="Действия с пользователем">
|
||||||
|
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">
|
||||||
|
${addIconHtml()}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
|
||||||
|
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
|
||||||
|
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
|
||||||
|
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
|
||||||
|
<span class="user-profile-detail-tab-label">Духовный путь</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
|
||||||
|
<span class="user-profile-detail-tab-label">Контакты</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>`;
|
||||||
|
|
||||||
|
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||||
|
avatarSlot?.append(renderUserAvatar({
|
||||||
|
login: card.login,
|
||||||
|
firstName: card.firstName,
|
||||||
|
lastName: card.lastName,
|
||||||
|
avatar: card.avatar,
|
||||||
|
size: 'xl',
|
||||||
|
className: 'user-profile-hero-avatar',
|
||||||
|
glow: shining,
|
||||||
|
}));
|
||||||
|
|
||||||
|
addMenu = body.querySelector('.user-profile-add-menu');
|
||||||
|
addActionButton = body.querySelector('[data-profile-action="add"]');
|
||||||
|
updateRelationUi();
|
||||||
|
status.textContent = '';
|
||||||
|
|
||||||
|
if (!isSelf && selfLogin) {
|
||||||
|
void ensureRelationFlags().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.addEventListener('click', async (event) => {
|
||||||
|
if (!card) return;
|
||||||
|
const listButton = event.target.closest('[data-profile-list]');
|
||||||
|
if (listButton) {
|
||||||
|
const kind = listButton.dataset.profileList;
|
||||||
|
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailButton = event.target.closest('[data-profile-detail]');
|
||||||
|
if (detailButton) {
|
||||||
|
const detailKind = detailButton.dataset.profileDetail;
|
||||||
|
const detailPanel = body.querySelector('#user-profile-detail-panel');
|
||||||
|
if (!detailPanel) return;
|
||||||
|
|
||||||
|
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
|
||||||
|
const active = button === detailButton;
|
||||||
|
button.classList.toggle('is-active', active);
|
||||||
|
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailKind === 'spiritual-path') {
|
||||||
|
detailPanel.innerHTML = spiritualPathDetailHtml(card);
|
||||||
|
} else if (detailKind === 'contacts') {
|
||||||
|
detailPanel.innerHTML = contactsDetailHtml(card);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailPanel.hidden = false;
|
||||||
|
detailPanel.dataset.activeDetail = detailKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationButton = event.target.closest('[data-relation-kind]');
|
||||||
|
if (relationButton) {
|
||||||
|
if (!selfLogin) {
|
||||||
|
status.className = 'status-line user-profile-status is-unavailable';
|
||||||
|
status.textContent = 'Для добавления пользователя необходимо войти.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = relationButton.dataset.relationKind;
|
||||||
|
try {
|
||||||
|
addMenu?.classList.add('is-busy');
|
||||||
|
await changeSocial(next);
|
||||||
|
if (addMenu) addMenu.hidden = true;
|
||||||
|
addActionButton?.setAttribute('aria-expanded', 'false');
|
||||||
|
status.className = 'status-line user-profile-status';
|
||||||
|
status.textContent = '';
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line user-profile-status is-unavailable';
|
||||||
|
status.textContent = `Ошибка: ${error?.message || 'Не удалось изменить связь'}`;
|
||||||
|
} finally {
|
||||||
|
addMenu?.classList.remove('is-busy');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionButton = event.target.closest('[data-profile-action]');
|
||||||
|
const action = actionButton?.dataset.profileAction;
|
||||||
|
if (action === 'add') {
|
||||||
|
if (!addMenu) return;
|
||||||
|
navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'links') {
|
||||||
|
navigate(makeProfileLinksRoute(card.login));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'chat') {
|
||||||
|
navigate(`chat/${encodeURIComponent(card.login)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOutsidePointer = (event) => {
|
||||||
|
if (!addMenu || addMenu.hidden) return;
|
||||||
|
if (event.target instanceof Node && body.contains(event.target)) {
|
||||||
|
const insideActions = event.target.closest?.('.user-profile-actions-wrap');
|
||||||
|
if (insideActions) return;
|
||||||
|
}
|
||||||
|
addMenu.hidden = true;
|
||||||
|
addActionButton?.setAttribute('aria-expanded', 'false');
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handleOutsidePointer);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
card = await loadUserProfileCard(requestedLogin);
|
||||||
|
renderProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh().catch((error) => {
|
||||||
|
status.className = 'status-line user-profile-status is-unavailable';
|
||||||
|
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.cleanup = () => {
|
||||||
|
document.removeEventListener('pointerdown', handleOutsidePointer);
|
||||||
|
};
|
||||||
|
|
||||||
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const PRETTY_PATHS = new Map([
|
|||||||
['key-storage-view', 'key-storage'],
|
['key-storage-view', 'key-storage'],
|
||||||
['profile-view', 'profile'],
|
['profile-view', 'profile'],
|
||||||
['profile-edit-view', 'profile/edit'],
|
['profile-edit-view', 'profile/edit'],
|
||||||
|
['profiles-view', 'profiles'],
|
||||||
['messages-list', 'messages'],
|
['messages-list', 'messages'],
|
||||||
['contact-search-view', 'contacts'],
|
['contact-search-view', 'contacts'],
|
||||||
['chat-view', 'chat'],
|
['chat-view', 'chat'],
|
||||||
@@ -248,6 +249,10 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
return { pageId: 'profile-view', params: {} };
|
return { pageId: 'profile-view', params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'profiles') {
|
||||||
|
return { pageId: 'profiles-view', params: {} };
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'messages') {
|
if (pageId === 'messages') {
|
||||||
return { pageId: 'messages-list', params: {} };
|
return { pageId: 'messages-list', params: {} };
|
||||||
}
|
}
|
||||||
@@ -437,6 +442,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
) return pageId;
|
) return pageId;
|
||||||
if (
|
if (
|
||||||
pageId === 'profile-edit-view' ||
|
pageId === 'profile-edit-view' ||
|
||||||
|
pageId === 'profiles-view' ||
|
||||||
pageId === 'wallet-view' ||
|
pageId === 'wallet-view' ||
|
||||||
pageId === 'settings-view' ||
|
pageId === 'settings-view' ||
|
||||||
pageId === 'access-servers-view' ||
|
pageId === 'access-servers-view' ||
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ function uint8Bytes(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||||
|
const NTF_PREFIX_V1 = utf8Bytes('SHiNE_NTF');
|
||||||
|
const NTF_FORMAT_VERSION_MAJOR = 1;
|
||||||
|
const NTF_FORMAT_VERSION_MINOR = 0;
|
||||||
|
const NTF_STATE_SEEN_WATERMARK = 1;
|
||||||
|
const NTF_CATEGORY = { replies: 1, connections: 2, events: 3 };
|
||||||
const DM_TYPE_INCOMING = 1;
|
const DM_TYPE_INCOMING = 1;
|
||||||
const DM_TYPE_OUTGOING_COPY = 2;
|
const DM_TYPE_OUTGOING_COPY = 2;
|
||||||
const DM_TYPE_READ_INCOMING = 3;
|
const DM_TYPE_READ_INCOMING = 3;
|
||||||
@@ -994,6 +999,8 @@ export class AuthService {
|
|||||||
constructor(serverUrl) {
|
constructor(serverUrl) {
|
||||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
this.ws = new WsJsonClient(this.serverUrl);
|
||||||
|
this.eventListeners = new Map();
|
||||||
|
this.wsEventUnsubscribers = new Map();
|
||||||
this.headerHashCache = new Map();
|
this.headerHashCache = new Map();
|
||||||
this.writeLocks = new Map();
|
this.writeLocks = new Map();
|
||||||
this.passwordKeyBundleCache = new Map();
|
this.passwordKeyBundleCache = new Map();
|
||||||
@@ -1003,14 +1010,39 @@ export class AuthService {
|
|||||||
this.remoteAddBlockSessionId = '';
|
this.remoteAddBlockSessionId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconnect(serverUrl) {
|
bindRegisteredEventsToCurrentWs() {
|
||||||
|
this.wsEventUnsubscribers.forEach((unsubscribe) => {
|
||||||
|
try { unsubscribe?.(); } catch {}
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.clear();
|
||||||
|
|
||||||
|
this.eventListeners.forEach((_handlers, op) => {
|
||||||
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
if (!handlers) return;
|
||||||
|
handlers.forEach((handler) => {
|
||||||
|
try { handler(data); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetConnection(serverUrl = this.serverUrl, { clearSessionContext = true } = {}) {
|
||||||
const normalized = normalizeServerUrl(serverUrl);
|
const normalized = normalizeServerUrl(serverUrl);
|
||||||
if (normalized === this.serverUrl) return;
|
try { this.ws?.close(); } catch {}
|
||||||
this.ws.close();
|
|
||||||
this.serverUrl = normalized;
|
this.serverUrl = normalized;
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
this.ws = new WsJsonClient(this.serverUrl);
|
||||||
this.headerHashCache = new Map();
|
this.headerHashCache = new Map();
|
||||||
this.writeLocks.clear();
|
this.writeLocks.clear();
|
||||||
|
this.bindRegisteredEventsToCurrentWs();
|
||||||
|
if (clearSessionContext) this.clearActiveSessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(serverUrl) {
|
||||||
|
const normalized = normalizeServerUrl(serverUrl);
|
||||||
|
if (normalized === this.serverUrl) return;
|
||||||
|
this.resetConnection(normalized, { clearSessionContext: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
||||||
@@ -2509,7 +2541,28 @@ export class AuthService {
|
|||||||
|
|
||||||
|
|
||||||
onEvent(op, handler) {
|
onEvent(op, handler) {
|
||||||
return this.ws.onEvent(op, handler);
|
if (!op || typeof handler !== 'function') return () => {};
|
||||||
|
if (!this.eventListeners.has(op)) {
|
||||||
|
this.eventListeners.set(op, new Set());
|
||||||
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
if (!handlers) return;
|
||||||
|
handlers.forEach((callback) => {
|
||||||
|
try { callback(data); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
||||||
|
}
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size) return;
|
||||||
|
this.eventListeners.delete(op);
|
||||||
|
const unsubscribe = this.wsEventUnsubscribers.get(op);
|
||||||
|
try { unsubscribe?.(); } catch {}
|
||||||
|
this.wsEventUnsubscribers.delete(op);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
||||||
@@ -2891,14 +2944,40 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotifications(limit = 50) {
|
async getNotifications(countsOnly = false) {
|
||||||
const payload = {};
|
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
||||||
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);
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setNotificationSeen({ login, category, seenAtMs, storagePwd }) {
|
||||||
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
|
const cleanCategory = String(category || '').trim().toLowerCase();
|
||||||
|
const categoryCode = NTF_CATEGORY[cleanCategory];
|
||||||
|
if (!cleanLogin || !categoryCode) throw new Error('Некорректный login/category уведомлений');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи состояния уведомлений');
|
||||||
|
const normalizedSeenAtMs = Math.max(0, Math.trunc(Number(seenAtMs || 0)));
|
||||||
|
const timeMs = Date.now();
|
||||||
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const clientPriv = secrets?.clientKey;
|
||||||
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||||
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||||
|
const loginBytes = ensureAsciiBytes(cleanLogin, 'login');
|
||||||
|
const preimage = concatBytes(
|
||||||
|
NTF_PREFIX_V1,
|
||||||
|
uint8Bytes(NTF_FORMAT_VERSION_MAJOR), uint8Bytes(NTF_FORMAT_VERSION_MINOR),
|
||||||
|
uint8Bytes(loginBytes.length), loginBytes,
|
||||||
|
uint64Bytes(timeMs), uint32Bytes(nonce),
|
||||||
|
uint8Bytes(NTF_STATE_SEEN_WATERMARK), uint8Bytes(categoryCode),
|
||||||
|
uint64Bytes(normalizedSeenAtMs),
|
||||||
|
);
|
||||||
|
const signature = await signBytes(privateKey, preimage);
|
||||||
|
const response = await this.ws.request('SetNotificationState', { blobB64: bytesToBase64(concatBytes(preimage, signature)) });
|
||||||
|
if (response.status !== 200) throw opError('SetNotificationState', 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);
|
||||||
|
|||||||
@@ -1,16 +1,67 @@
|
|||||||
const DB_NAME = 'shine-ui-messages-v1';
|
const DB_NAME = 'shine-ui-messages-v1';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 3;
|
||||||
const STORE_MESSAGES = 'messages';
|
const STORE_MESSAGES = 'messages_by_profile';
|
||||||
|
const LEGACY_STORE_MESSAGES = 'messages';
|
||||||
|
const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
|
||||||
|
const LEGACY_SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
|
||||||
|
function normalizeOwnerLogin(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(ownerLogin, messageKey) {
|
||||||
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
|
const key = String(messageKey || '').trim();
|
||||||
|
return owner && key ? `${owner}|${key}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationOwnerLogin() {
|
||||||
|
try {
|
||||||
|
const active = normalizeOwnerLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
|
||||||
|
if (active) return active;
|
||||||
|
const legacy = JSON.parse(localStorage.getItem(LEGACY_SESSION_STORAGE_KEY) || '{}');
|
||||||
|
return normalizeOwnerLogin(legacy?.login);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndexes(store) {
|
||||||
|
if (!store.indexNames.contains('by_chat')) store.createIndex('by_chat', 'chatId', { unique: false });
|
||||||
|
if (!store.indexNames.contains('by_ts')) store.createIndex('by_ts', 'ts', { unique: false });
|
||||||
|
if (!store.indexNames.contains('by_owner')) store.createIndex('by_owner', 'ownerLogin', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
function openDb() {
|
function openDb() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
|
let store;
|
||||||
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||||
const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
|
store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'storageKey' });
|
||||||
store.createIndex('by_chat', 'chatId', { unique: false });
|
} else {
|
||||||
store.createIndex('by_ts', 'ts', { unique: false });
|
store = request.transaction.objectStore(STORE_MESSAGES);
|
||||||
|
}
|
||||||
|
ensureIndexes(store);
|
||||||
|
|
||||||
|
// Однократная миграция старого single-profile кэша в пространство текущего профиля.
|
||||||
|
if (db.objectStoreNames.contains(LEGACY_STORE_MESSAGES)) {
|
||||||
|
const owner = migrationOwnerLogin();
|
||||||
|
if (owner) {
|
||||||
|
const legacy = request.transaction.objectStore(LEGACY_STORE_MESSAGES);
|
||||||
|
const cursorReq = legacy.openCursor();
|
||||||
|
cursorReq.onsuccess = () => {
|
||||||
|
const cursor = cursorReq.result;
|
||||||
|
if (!cursor) return;
|
||||||
|
const row = cursor.value || {};
|
||||||
|
const messageKey = String(row.messageKey || '').trim();
|
||||||
|
const rowOwner = normalizeOwnerLogin(row.ownerLogin) || owner;
|
||||||
|
const key = storageKey(rowOwner, messageKey);
|
||||||
|
if (key) store.put({ ...row, ownerLogin: rowOwner, storageKey: key });
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -36,31 +87,55 @@ async function withStore(mode, callback) {
|
|||||||
|
|
||||||
export async function putStoredMessage(record) {
|
export async function putStoredMessage(record) {
|
||||||
if (!record || !record.messageKey) return;
|
if (!record || !record.messageKey) return;
|
||||||
|
const ownerLogin = normalizeOwnerLogin(record.ownerLogin);
|
||||||
|
const key = storageKey(ownerLogin, record.messageKey);
|
||||||
|
if (!key) return;
|
||||||
await withStore('readwrite', (store) => {
|
await withStore('readwrite', (store) => {
|
||||||
store.put(record);
|
store.put({ ...record, ownerLogin, storageKey: key });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStoredMessage(messageKey) {
|
export async function deleteStoredMessage(messageKey, ownerLogin = '') {
|
||||||
if (!messageKey) return;
|
const key = storageKey(ownerLogin, messageKey);
|
||||||
|
if (!key) return;
|
||||||
await withStore('readwrite', (store) => {
|
await withStore('readwrite', (store) => {
|
||||||
store.delete(messageKey);
|
store.delete(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listStoredMessages() {
|
export async function listStoredMessages(ownerLogin = '') {
|
||||||
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
|
if (!owner) return [];
|
||||||
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
||||||
const req = store.getAll();
|
const req = store.index('by_owner').getAll(owner);
|
||||||
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
||||||
req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
|
req.onerror = () => reject(req.error || new Error('IndexedDB getAll by owner failed'));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStoredMessages() {
|
export async function clearStoredMessages(ownerLogin = '') {
|
||||||
await new Promise((resolve, reject) => {
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
if (!owner) {
|
||||||
request.onsuccess = () => resolve();
|
await new Promise((resolve, reject) => {
|
||||||
request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
|
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||||
request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
|
request.onsuccess = () => resolve();
|
||||||
});
|
request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
|
||||||
|
request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await withStore('readwrite', (store) => new Promise((resolve, reject) => {
|
||||||
|
const index = store.index('by_owner');
|
||||||
|
const req = index.openKeyCursor(IDBKeyRange.only(owner));
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const cursor = req.result;
|
||||||
|
if (!cursor) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.delete(cursor.primaryKey);
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error || new Error('IndexedDB clear by owner failed'));
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+350
-3
@@ -11,6 +11,8 @@ import { emptyPasswordWords } from './services/password-words.js';
|
|||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
const PROFILES_STORAGE_KEY = 'shine-ui-profiles-v1';
|
||||||
|
const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
|
||||||
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
||||||
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
||||||
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
||||||
@@ -122,7 +124,121 @@ function normalizeToolsSettings(rawTools) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeProfileLogin(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProfileStoreRaw() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROFILES_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((item) => item && normalizeProfileLogin(item.login) && String(item.sessionId || '').trim());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistProfileStoreRaw(items) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(Array.isArray(items) ? items : []));
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveProfileLoginRaw() {
|
||||||
|
try {
|
||||||
|
return normalizeProfileLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveProfileLoginRaw(login) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
try {
|
||||||
|
if (normalized) localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, normalized);
|
||||||
|
else localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileEntrySettingsSnapshot(settings = {}) {
|
||||||
|
return {
|
||||||
|
solanaServer: String(settings.solanaServer || ''),
|
||||||
|
shineServer: String(settings.shineServer || ''),
|
||||||
|
shineServerLogin: String(settings.shineServerLogin || ''),
|
||||||
|
shineServerHttp: String(settings.shineServerHttp || ''),
|
||||||
|
arweaveServer: String(settings.arweaveServer || ''),
|
||||||
|
callPreflightTimeoutMs: Number(settings.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS),
|
||||||
|
remoteAddBlockSessionId: String(settings.remoteAddBlockSessionId || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertSavedProfileInternal({ login, sessionId, isLocalDemo = false, entrySettings = null } = {}) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
const cleanSessionId = String(sessionId || '').trim();
|
||||||
|
if (!normalized || !cleanSessionId) return;
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
|
||||||
|
const previous = index >= 0 ? items[index] : {};
|
||||||
|
const next = {
|
||||||
|
...previous,
|
||||||
|
login: String(login || '').trim(),
|
||||||
|
sessionId: cleanSessionId,
|
||||||
|
isLocalDemo: Boolean(isLocalDemo),
|
||||||
|
entrySettings: entrySettings ? profileEntrySettingsSnapshot(entrySettings) : (previous.entrySettings || {}),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
if (index >= 0) items[index] = next;
|
||||||
|
else items.push(next);
|
||||||
|
persistProfileStoreRaw(items);
|
||||||
|
setActiveProfileLoginRaw(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacySessionToProfileStore() {
|
||||||
|
const existing = loadProfileStoreRaw();
|
||||||
|
if (existing.length) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const legacy = JSON.parse(raw);
|
||||||
|
if (!legacy?.login || !legacy?.sessionId) return;
|
||||||
|
let entrySettings = {};
|
||||||
|
try {
|
||||||
|
entrySettings = JSON.parse(localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY) || '{}') || {};
|
||||||
|
} catch {}
|
||||||
|
upsertSavedProfileInternal({
|
||||||
|
login: legacy.login,
|
||||||
|
sessionId: legacy.sessionId,
|
||||||
|
isLocalDemo: legacy.isLocalDemo,
|
||||||
|
entrySettings,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore migration errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadStoredSession() {
|
function loadStoredSession() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
if (profiles.length) {
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
|
||||||
|
if (active) {
|
||||||
|
setActiveProfileLoginRaw(active.login);
|
||||||
|
return {
|
||||||
|
isAuthorized: false,
|
||||||
|
isLocalDemo: Boolean(active.isLocalDemo),
|
||||||
|
login: String(active.login || '').trim(),
|
||||||
|
sessionId: String(active.sessionId || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -169,6 +285,15 @@ function clearStoredSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredEntrySettings() {
|
function loadStoredEntrySettings() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
if (profiles.length) {
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
|
||||||
|
if (active?.entrySettings && typeof active.entrySettings === 'object') {
|
||||||
|
return active.entrySettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
|
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -199,6 +324,19 @@ function persistEntrySettings(settings) {
|
|||||||
tools: normalizeToolsSettings(settings?.tools),
|
tools: normalizeToolsSettings(settings?.tools),
|
||||||
};
|
};
|
||||||
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
if (activeLogin) {
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
const index = profiles.findIndex((item) => normalizeProfileLogin(item.login) === activeLogin);
|
||||||
|
if (index >= 0) {
|
||||||
|
profiles[index] = {
|
||||||
|
...profiles[index],
|
||||||
|
entrySettings: profileEntrySettingsSnapshot(payload),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
persistProfileStoreRaw(profiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
@@ -244,6 +382,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
pendingIncomingReadByBaseKey: {},
|
pendingIncomingReadByBaseKey: {},
|
||||||
outgoingTempSeq: 1,
|
outgoingTempSeq: 1,
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
|
notificationUnreadTotal: 0,
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
isAuthorized: storedLocalDemo,
|
isAuthorized: storedLocalDemo,
|
||||||
@@ -401,6 +540,7 @@ function persistMessageRecord(chatId, row) {
|
|||||||
const resolvedTs = resolveChatMessageTimeMs(row);
|
const resolvedTs = resolveChatMessageTimeMs(row);
|
||||||
void putStoredMessage({
|
void putStoredMessage({
|
||||||
messageKey: row.messageKey,
|
messageKey: row.messageKey,
|
||||||
|
ownerLogin: String(state.session.login || '').trim().toLowerCase(),
|
||||||
chatId: normalizedChatId,
|
chatId: normalizedChatId,
|
||||||
from: row.from || 'in',
|
from: row.from || 'in',
|
||||||
text: String(row.text || ''),
|
text: String(row.text || ''),
|
||||||
@@ -421,12 +561,12 @@ function persistMessageRecord(chatId, row) {
|
|||||||
|
|
||||||
function removeStoredMessageRecord(messageKey) {
|
function removeStoredMessageRecord(messageKey) {
|
||||||
if (!messageKey) return;
|
if (!messageKey) return;
|
||||||
void deleteStoredMessage(messageKey).catch(() => {});
|
void deleteStoredMessage(messageKey, state.session.login).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hydrateMessagesFromStore() {
|
export async function hydrateMessagesFromStore() {
|
||||||
try {
|
try {
|
||||||
const rows = await listStoredMessages();
|
const rows = await listStoredMessages(state.session.login);
|
||||||
const touchedChats = new Set();
|
const touchedChats = new Set();
|
||||||
rows
|
rows
|
||||||
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
||||||
@@ -927,6 +1067,7 @@ export function authorizeSession({
|
|||||||
login,
|
login,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings });
|
||||||
authService.setActiveSessionContext({ login, sessionId });
|
authService.setActiveSessionContext({ login, sessionId });
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
if (onSessionAuthorized) {
|
if (onSessionAuthorized) {
|
||||||
@@ -1019,13 +1160,14 @@ async function tryCloseCurrentSessionOnServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
||||||
|
const signedOutLogin = String(state.session.login || '').trim();
|
||||||
if (closeServerSession) {
|
if (closeServerSession) {
|
||||||
await tryCloseCurrentSessionOnServer();
|
await tryCloseCurrentSessionOnServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(signedOutLogin).catch(() => {});
|
||||||
authService.close();
|
authService.close();
|
||||||
authService.clearActiveSessionContext();
|
authService.clearActiveSessionContext();
|
||||||
if (infoMessage) {
|
if (infoMessage) {
|
||||||
@@ -1045,6 +1187,211 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
|||||||
await terminateCurrentSession({ infoMessage, closeServerSession: true });
|
await terminateCurrentSession({ infoMessage, closeServerSession: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getSavedProfiles() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
return loadProfileStoreRaw().map((item) => ({
|
||||||
|
login: String(item.login || '').trim(),
|
||||||
|
sessionId: String(item.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(item.isLocalDemo),
|
||||||
|
isActive: normalizeProfileLogin(item.login) === activeLogin,
|
||||||
|
entrySettings: item.entrySettings || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchToSavedProfile(login) {
|
||||||
|
const targetLogin = normalizeProfileLogin(login);
|
||||||
|
const target = loadProfileStoreRaw().find((item) => normalizeProfileLogin(item.login) === targetLogin);
|
||||||
|
if (!target) throw new Error('Профиль не найден на этом устройстве');
|
||||||
|
if (targetLogin === normalizeProfileLogin(state.session.login)) return target;
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
sessionId: String(state.session.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(state.session.isLocalDemo),
|
||||||
|
server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(),
|
||||||
|
};
|
||||||
|
const targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
|
||||||
|
// В каждый момент времени держим только один WebSocket: сначала полностью
|
||||||
|
// закрываем transport активного профиля, затем создаём новый для target.
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
try {
|
||||||
|
const resumed = await authService.resumeSession(target.login, target.sessionId);
|
||||||
|
target.login = resumed.login || target.login;
|
||||||
|
target.sessionId = resumed.sessionId || target.sessionId;
|
||||||
|
target.updatedAtMs = Date.now();
|
||||||
|
authService.setActiveSessionContext({ login: target.login, sessionId: target.sessionId });
|
||||||
|
persistProfileStoreRaw(loadProfileStoreRaw().map((item) => (
|
||||||
|
normalizeProfileLogin(item.login) === targetLogin ? target : item
|
||||||
|
)));
|
||||||
|
setActiveProfileLoginRaw(target.login);
|
||||||
|
persistSession({ isAuthorized: true, isLocalDemo: Boolean(target.isLocalDemo), login: target.login, sessionId: target.sessionId });
|
||||||
|
if (target.entrySettings && typeof target.entrySettings === 'object') {
|
||||||
|
persistEntrySettings({ ...state.entrySettings, ...target.entrySettings });
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
} catch (switchError) {
|
||||||
|
// Если новый профиль не поднялся — создаём новый socket обратно для старого.
|
||||||
|
try {
|
||||||
|
authService.resetConnection(origin.server, { clearSessionContext: true });
|
||||||
|
if (origin.login && origin.sessionId && !origin.isLocalDemo) {
|
||||||
|
const restored = await authService.resumeSession(origin.login, origin.sessionId);
|
||||||
|
authService.setActiveSessionContext({
|
||||||
|
login: restored?.login || origin.login,
|
||||||
|
sessionId: restored?.sessionId || origin.sessionId,
|
||||||
|
});
|
||||||
|
} else if (origin.login) {
|
||||||
|
authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId });
|
||||||
|
}
|
||||||
|
} catch (restoreError) {
|
||||||
|
console.warn('[profiles] failed to restore previous profile connection after switch failure', restoreError);
|
||||||
|
}
|
||||||
|
throw switchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSavedProfileSessionBestEffort(profile) {
|
||||||
|
if (!profile || profile.isLocalDemo) return;
|
||||||
|
const cleanSessionId = String(profile.sessionId || '').trim();
|
||||||
|
if (!cleanSessionId) return;
|
||||||
|
const normalized = normalizeProfileLogin(profile.login);
|
||||||
|
const activeNormalized = normalizeProfileLogin(state.session.login);
|
||||||
|
if (normalized === activeNormalized && state.session.isAuthorized) {
|
||||||
|
try { await authService.closeSession(cleanSessionId); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
sessionId: String(state.session.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(state.session.isLocalDemo),
|
||||||
|
server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(),
|
||||||
|
};
|
||||||
|
const targetServer = String(profile?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
try {
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(profile.login, cleanSessionId);
|
||||||
|
await authService.closeSession(cleanSessionId);
|
||||||
|
} catch {
|
||||||
|
// Закрытие профиля на устройстве не блокируем из-за недоступного сервера.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
authService.resetConnection(origin.server, { clearSessionContext: true });
|
||||||
|
if (origin.login && origin.sessionId && !origin.isLocalDemo) {
|
||||||
|
const restored = await authService.resumeSession(origin.login, origin.sessionId);
|
||||||
|
authService.setActiveSessionContext({
|
||||||
|
login: restored?.login || origin.login,
|
||||||
|
sessionId: restored?.sessionId || origin.sessionId,
|
||||||
|
});
|
||||||
|
} else if (origin.login) {
|
||||||
|
authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[profiles] failed to restore active profile after closing another profile', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSavedProfile(login) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
|
||||||
|
if (index < 0) return { closed: false, nextProfile: null };
|
||||||
|
const target = items[index];
|
||||||
|
await closeSavedProfileSessionBestEffort(target);
|
||||||
|
await clearStoredMessages(target.login).catch(() => {});
|
||||||
|
|
||||||
|
const nextItems = items.filter((_, itemIndex) => itemIndex !== index);
|
||||||
|
persistProfileStoreRaw(nextItems);
|
||||||
|
const wasActive = normalized === getActiveProfileLoginRaw();
|
||||||
|
if (!wasActive) return { closed: true, nextProfile: null };
|
||||||
|
|
||||||
|
const next = nextItems[index] || nextItems[index - 1] || nextItems[0] || null;
|
||||||
|
if (!next) {
|
||||||
|
setActiveProfileLoginRaw('');
|
||||||
|
clearStoredSession();
|
||||||
|
return { closed: true, nextProfile: null };
|
||||||
|
}
|
||||||
|
setActiveProfileLoginRaw(next.login);
|
||||||
|
persistSession({ isAuthorized: true, isLocalDemo: Boolean(next.isLocalDemo), login: next.login, sessionId: next.sessionId });
|
||||||
|
if (next.entrySettings && typeof next.entrySettings === 'object') {
|
||||||
|
persistEntrySettings({ ...state.entrySettings, ...next.entrySettings });
|
||||||
|
}
|
||||||
|
return { closed: true, nextProfile: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeAllSavedProfiles() {
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
for (const item of items) {
|
||||||
|
await clearStoredMessages(item.login).catch(() => {});
|
||||||
|
if (item.isLocalDemo || !String(item.sessionId || '').trim()) continue;
|
||||||
|
const targetServer = String(item?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
try {
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(item.login, item.sessionId);
|
||||||
|
await authService.closeSession(item.sessionId);
|
||||||
|
} catch {
|
||||||
|
// Все локальные профили всё равно закрываем, даже если один сервер недоступен.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistProfileStoreRaw([]);
|
||||||
|
setActiveProfileLoginRaw('');
|
||||||
|
clearStoredSession();
|
||||||
|
authService.close();
|
||||||
|
authService.clearActiveSessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAddingProfileLogin() {
|
||||||
|
return state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareAddProfileLogin() {
|
||||||
|
state.loginDraft.login = '';
|
||||||
|
state.loginDraft.password = '';
|
||||||
|
clearAuthMessages();
|
||||||
|
// While an existing profile stays authorized, PRE_AUTH login pages are normally
|
||||||
|
// blocked by app.js. This return target also acts as an explicit add-profile mode.
|
||||||
|
state.authReturnHash = '/profiles';
|
||||||
|
|
||||||
|
// Не пытаемся авторизовать второй login через уже authenticated socket.
|
||||||
|
// Старую серверную сессию НЕ закрываем: закрываем только локальный transport.
|
||||||
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAddProfileLogin() {
|
||||||
|
const wasAddingProfile = isAddingProfileLogin();
|
||||||
|
const shouldRestoreActiveConnection = wasAddingProfile
|
||||||
|
&& state.session.isAuthorized
|
||||||
|
&& Boolean(String(state.session.login || '').trim())
|
||||||
|
&& Boolean(String(state.session.sessionId || '').trim());
|
||||||
|
|
||||||
|
state.authReturnHash = '';
|
||||||
|
state.loginDraft.login = '';
|
||||||
|
state.loginDraft.password = '';
|
||||||
|
resetRegistrationFlow();
|
||||||
|
clearAuthMessages();
|
||||||
|
|
||||||
|
if (shouldRestoreActiveConnection) {
|
||||||
|
try {
|
||||||
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(state.session.login, state.session.sessionId);
|
||||||
|
authService.setActiveSessionContext({ login: state.session.login, sessionId: state.session.sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[profiles] failed to restore active profile connection after cancelling add-profile flow', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wasAddingProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeAuthReturnPage(fallback = 'profile-view') {
|
||||||
|
const nextHash = String(state.authReturnHash || '').trim();
|
||||||
|
state.authReturnHash = '';
|
||||||
|
if (nextHash.startsWith('/')) return nextHash.slice(1) || fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshRegistrationBalance() {
|
export function refreshRegistrationBalance() {
|
||||||
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
||||||
state.registrationPayment.balanceSOL = next;
|
state.registrationPayment.balanceSOL = next;
|
||||||
|
|||||||
+1040
-6
File diff suppressed because it is too large
Load Diff
@@ -62,3 +62,14 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-card--new { background: rgba(108, 92, 231, .10); border-color: rgba(143, 126, 255, .42); }
|
||||||
|
.notification-card--new::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; position: absolute; right: 12px; top: 12px; opacity: .9; }
|
||||||
|
.notification-card { position: relative; }
|
||||||
|
.notification-new-divider { text-align: center; font-size: 11px; letter-spacing: .12em; opacity: .72; padding: 6px 0; }
|
||||||
|
|
||||||
|
.channel-view-topbar { position: relative; }
|
||||||
|
.channel-header-more-menu { position: absolute; right: 10px; top: calc(100% - 4px); z-index: 80; min-width: 190px; padding: 7px; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: rgba(18,18,28,.96); backdrop-filter: blur(18px); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
|
||||||
|
.channel-header-more-menu button { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 10px 12px; border-radius: 10px; }
|
||||||
|
.channel-header-more-menu button:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.channel-header-more-menu button.is-danger { color: #ff8c9b; }
|
||||||
|
|||||||
@@ -242,10 +242,9 @@
|
|||||||
.fg-node.is-pressed .node-dot { transform: none; }
|
.fg-node.is-pressed .node-dot { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
||||||
Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр).
|
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
|
||||||
Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 ↔ 1.0 —
|
визуально сияющий пользователь всегда остаётся явно сияющим. */
|
||||||
как мягкое свечение живого организма в темноте, а не «жирный маркер». */
|
|
||||||
.fg-node.is-shine .node-dot {
|
.fg-node.is-shine .node-dot {
|
||||||
border-color: rgba(150, 240, 255, 0.62);
|
border-color: rgba(150, 240, 255, 0.62);
|
||||||
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
||||||
@@ -268,9 +267,9 @@
|
|||||||
@keyframes fg-shine-glow {
|
@keyframes fg-shine-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 5px rgba(125, 232, 255, 0.30),
|
0 0 7px rgba(138, 239, 255, 0.48),
|
||||||
0 0 11px rgba(112, 226, 255, 0.18),
|
0 0 15px rgba(118, 232, 255, 0.32),
|
||||||
0 0 20px rgba(100, 220, 255, 0.10);
|
0 0 27px rgba(100, 220, 255, 0.19);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -282,7 +281,7 @@
|
|||||||
|
|
||||||
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
||||||
@keyframes fg-shine-halo {
|
@keyframes fg-shine-halo {
|
||||||
0%, 100% { transform: scale(0.9); opacity: 0.5; }
|
0%, 100% { transform: scale(0.98); opacity: 0.72; }
|
||||||
50% { transform: scale(1.12); opacity: 1; }
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user