SHA256
Compare commits
7
Commits
0c5089fa79
...
ui-artem
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a |
@@ -36,6 +36,7 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_17 = 17;
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
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_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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_V18_RESOURCE = "postgres/migration_v18.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() {}
|
||||
|
||||
@@ -200,6 +202,10 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||
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);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||
int unreadCount = rs.getInt("unread_count");
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
rs.getLong("last_message_time_ms"),
|
||||
rs.getInt("unread_count"),
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
true
|
||||
lastMessageTimeMs > 0 || unreadCount > 0
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
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 {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
|
||||
@@ -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)
|
||||
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;
|
||||
@@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login 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 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)
|
||||
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;
|
||||
|
||||
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_ListContacts_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_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
@@ -215,6 +217,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -305,6 +308,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
|
||||
+17
-10
@@ -916,16 +916,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent.
|
||||
if (msgType == 3
|
||||
&& (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF))
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
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;
|
||||
|
||||
+4
-1
@@ -82,7 +82,10 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
row.setUnreadCount(ownChannel
|
||||
? 0
|
||||
: ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+22
-89
@@ -1,94 +1,27 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger; 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.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.*; import shine.db.entities.UserNotificationEntry;
|
||||
import java.sql.Connection; import java.util.*;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
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){
|
||||
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","Операция доступна только для авторизованных пользователей");
|
||||
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
try(Connection c=DbController.getInstance().getConnection()){
|
||||
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||
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","Внутренняя ошибка сервера");}
|
||||
}
|
||||
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; }
|
||||
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();}
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
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 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 {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> connections = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> 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 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;} }
|
||||
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}')"
|
||||
@@ -24,8 +24,9 @@
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
> `unreadCount` для канала считается по `user_settings`.
|
||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `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-токена |
|
||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
|
||||
@@ -1,81 +1,42 @@
|
||||
# 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` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
## SetNotificationState
|
||||
|
||||
### Запрос
|
||||
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
|
||||
|
||||
Запрос:
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```text
|
||||
'SHiNE_NTF' 9 bytes ASCII
|
||||
formatVersionMajor u8 = 1
|
||||
formatVersionMinor u8 = 0
|
||||
loginLen u8
|
||||
login ASCII[loginLen]
|
||||
timeMs u64
|
||||
nonce u32
|
||||
stateType u8 = 1 (SEEN_WATERMARK)
|
||||
category u8 (1 replies, 2 connections, 3 events)
|
||||
seenAtMs u64
|
||||
signature Ed25519[64]
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
|
||||
|
||||
@@ -203,3 +203,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
||||
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||
- Для недавних сообщений 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)
|
||||
Это изменение не меняет байтовый формат 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. Формат контейнера при этом не изменяется.
|
||||
|
||||
+1278
-37
File diff suppressed because it is too large
Load Diff
@@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => {
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const existing = allClients.find((client) => {
|
||||
try {
|
||||
return client.url.includes('/index.html') || client.url.endsWith('/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const existing = allClients[0] || null;
|
||||
|
||||
const openUrlBase = './index.html';
|
||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||
const openUrl = (action === 'accept' || action === 'decline')
|
||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||
: openUrlBase;
|
||||
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
} else {
|
||||
existing.postMessage({
|
||||
type: 'SHINE_NOTIFICATION_CLICK',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
await existing.focus();
|
||||
|
||||
+4
-2
@@ -18,7 +18,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -43,7 +43,9 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<div id="topbar-slot" class="topbar-slot" hidden></div>
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="composer-slot" class="composer-slot" hidden></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot" hidden></div>
|
||||
<div class="app-shell-fade app-shell-fade--top" aria-hidden="true"></div>
|
||||
<div class="app-shell-fade app-shell-fade--bottom" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script>
|
||||
|
||||
+241
-37
@@ -33,6 +33,9 @@ import {
|
||||
addAppLogEntry,
|
||||
authorizeSession,
|
||||
hydrateMessagesFromStore,
|
||||
getSavedProfiles,
|
||||
closeSavedProfile,
|
||||
switchToSavedProfile,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
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 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 settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
@@ -132,6 +136,7 @@ const routes = {
|
||||
queue: publicSupportQueueView,
|
||||
'profile-view': profileView,
|
||||
'profile-edit-view': profileEditView,
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'access-servers-view': accessServersView,
|
||||
@@ -213,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||
'settings-view',
|
||||
'profiles-view',
|
||||
]);
|
||||
|
||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||
@@ -393,14 +399,88 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
syncDebug();
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
const MANAGED_SHELL_CLASSES = [
|
||||
'app-shell--top-fade',
|
||||
'app-shell--bottom-fade',
|
||||
'app-shell--bottom-fade-composer',
|
||||
'app-shell--bottom-fade-toolbar',
|
||||
'app-shell--fade-edge',
|
||||
'app-shell--content-under-topbar',
|
||||
'app-shell--content-under-bottom',
|
||||
'app-shell--scroll-nested',
|
||||
'app-shell--scroll-locked',
|
||||
'app-shell--scrollbar-hidden',
|
||||
];
|
||||
|
||||
const MANAGED_SCREEN_CLASSES = [
|
||||
'no-app-chrome',
|
||||
'preauth-flow',
|
||||
'filled-action-buttons',
|
||||
'settings-bordered-actions',
|
||||
];
|
||||
|
||||
const DEFAULT_SHELL_MODE = Object.freeze({
|
||||
topFade: true,
|
||||
bottomFade: false,
|
||||
bottomFadeAnchor: 'composer',
|
||||
fadeProfile: 'standard',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: false,
|
||||
scrollContainer: 'screen',
|
||||
scrollbar: 'auto',
|
||||
});
|
||||
|
||||
function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||
const source = mode && typeof mode === 'object' ? mode : {};
|
||||
const normalized = { ...DEFAULT_SHELL_MODE, ...source };
|
||||
if (!showAppChrome) {
|
||||
normalized.topFade = false;
|
||||
normalized.bottomFade = false;
|
||||
normalized.contentUnderTopbar = false;
|
||||
normalized.contentUnderBottom = false;
|
||||
}
|
||||
normalized.bottomFadeAnchor = normalized.bottomFadeAnchor === 'toolbar' ? 'toolbar' : 'composer';
|
||||
normalized.fadeProfile = normalized.fadeProfile === 'edge' ? 'edge' : 'standard';
|
||||
normalized.scrollContainer = ['screen', 'nested', 'locked'].includes(normalized.scrollContainer)
|
||||
? normalized.scrollContainer
|
||||
: 'screen';
|
||||
normalized.scrollbar = normalized.scrollbar === 'hidden' ? 'hidden' : 'auto';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applyShellMode(mode, showAppChrome = true) {
|
||||
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
|
||||
const normalized = normalizeShellMode(mode, showAppChrome);
|
||||
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-toolbar', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'toolbar');
|
||||
appShellEl.classList.toggle('app-shell--fade-edge', normalized.fadeProfile === 'edge');
|
||||
appShellEl.classList.toggle('app-shell--content-under-topbar', Boolean(normalized.contentUnderTopbar));
|
||||
appShellEl.classList.toggle('app-shell--content-under-bottom', Boolean(normalized.contentUnderBottom));
|
||||
appShellEl.classList.toggle('app-shell--scroll-nested', normalized.scrollContainer === 'nested');
|
||||
appShellEl.classList.toggle('app-shell--scroll-locked', normalized.scrollContainer === 'locked');
|
||||
appShellEl.classList.toggle('app-shell--scrollbar-hidden', normalized.scrollbar === 'hidden');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resetShellMode() {
|
||||
appShellEl?.classList.remove(...MANAGED_SHELL_CLASSES);
|
||||
}
|
||||
|
||||
function resetManagedScreenClasses() {
|
||||
screenEl?.classList.remove(...MANAGED_SCREEN_CLASSES);
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
slotEl.hidden = true;
|
||||
if (presenceClass) appShellEl?.classList.remove(presenceClass);
|
||||
setShellMetricVar(cssVarName, 0);
|
||||
}
|
||||
|
||||
function mountSlot(slotEl, cssVarName, node) {
|
||||
function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
if (node instanceof Node) {
|
||||
@@ -409,59 +489,89 @@ function mountSlot(slotEl, cssVarName, node) {
|
||||
} else {
|
||||
slotEl.hidden = true;
|
||||
}
|
||||
if (presenceClass) appShellEl?.classList.toggle(presenceClass, !slotEl.hidden);
|
||||
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
|
||||
}
|
||||
|
||||
function createChromeController(showAppChrome) {
|
||||
function createChromeController(showAppChrome, initialShellMode = {}) {
|
||||
let topbarNode = null;
|
||||
let composerNode = null;
|
||||
|
||||
const cleanupOwnedNode = (node) => {
|
||||
if (node && typeof node.cleanup === 'function') node.cleanup();
|
||||
};
|
||||
let shellMode = normalizeShellMode(initialShellMode, showAppChrome);
|
||||
let disposed = false;
|
||||
|
||||
const apply = () => {
|
||||
if (disposed) return;
|
||||
applyShellMode(shellMode, showAppChrome);
|
||||
if (!showAppChrome) {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
return;
|
||||
}
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode);
|
||||
mountSlot(composerEl, '--composer-height', composerNode);
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode, 'app-shell--has-topbar');
|
||||
mountSlot(composerEl, '--composer-height', composerNode, 'app-shell--has-composer');
|
||||
topbarHeightObserver?.sync?.();
|
||||
composerHeightObserver?.sync?.();
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
return {
|
||||
setTopbar(node = null) {
|
||||
topbarNode = node instanceof Node ? node : null;
|
||||
const nextTopbar = node instanceof Node ? node : null;
|
||||
if (topbarNode && topbarNode !== nextTopbar) cleanupOwnedNode(topbarNode);
|
||||
topbarNode = nextTopbar;
|
||||
apply();
|
||||
},
|
||||
setComposer(node = null) {
|
||||
composerNode = node instanceof Node ? node : null;
|
||||
const nextComposer = node instanceof Node ? node : null;
|
||||
if (composerNode && composerNode !== nextComposer) cleanupOwnedNode(composerNode);
|
||||
composerNode = nextComposer;
|
||||
apply();
|
||||
},
|
||||
setShellMode(nextMode = {}) {
|
||||
shellMode = normalizeShellMode({ ...shellMode, ...(nextMode || {}) }, showAppChrome);
|
||||
apply();
|
||||
},
|
||||
clear() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
apply();
|
||||
},
|
||||
suspend() {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
},
|
||||
resume() {
|
||||
apply();
|
||||
},
|
||||
dispose() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
disposed = true;
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
resetShellMode();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearKeepAliveEntries() {
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
@@ -745,6 +855,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() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
const pending = loadPendingCallPushAction();
|
||||
@@ -1167,14 +1348,15 @@ function renderPageFailureFallback(pageId, error) {
|
||||
wrap.append(card);
|
||||
screenEl.append(wrap);
|
||||
|
||||
resetManagedScreenClasses();
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
@@ -1197,7 +1379,8 @@ function renderApp() {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -1214,10 +1397,23 @@ function renderApp() {
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
// Сначала полностью очищаем page-owned UI предыдущего маршрута, затем
|
||||
// применяем новый shell mode и только после этого монтируем следующий экран.
|
||||
screenEl.innerHTML = '';
|
||||
resetManagedScreenClasses();
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
try {
|
||||
screenEl.innerHTML = '';
|
||||
const chrome = createChromeController(showAppChrome);
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
@@ -1231,16 +1427,6 @@ function renderApp() {
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
@@ -1255,9 +1441,9 @@ function refreshToolbarOnly() {
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
@@ -1286,6 +1472,11 @@ async function tryAutoLogin() {
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
if (result?.nextProfile) {
|
||||
window.location.assign('/profile');
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
@@ -1338,6 +1529,7 @@ async function ensureSessionRuntimeStarted() {
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
if (state.session.isLocalDemo) {
|
||||
@@ -1373,12 +1565,20 @@ async function init() {
|
||||
const action = String(data.action || '').trim().toLowerCase();
|
||||
const payload = data.payload || {};
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||
savePendingCallPushAction(action, payload);
|
||||
void processPendingCallPushActionIfPossible();
|
||||
void (async () => {
|
||||
const canHandle = await ensurePushTargetProfile(payload, { action });
|
||||
if (!canHandle) return;
|
||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||
savePendingCallPushAction(action, payload);
|
||||
await processPendingCallPushActionIfPossible();
|
||||
})();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'SHINE_NOTIFICATION_CLICK') {
|
||||
void handleNotificationClick(data.payload || {});
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||
|
||||
const payload = data.payload || {};
|
||||
@@ -1411,7 +1611,8 @@ async function init() {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -1714,6 +1915,9 @@ async function init() {
|
||||
void (async () => {
|
||||
try {
|
||||
await tryAutoLogin();
|
||||
if (initialNotificationOpenPayload) {
|
||||
await handleNotificationClick(initialNotificationOpenPayload);
|
||||
}
|
||||
await hydrateMessagesFromStore();
|
||||
if (!state.session.isLocalDemo) {
|
||||
startConnectionMonitor();
|
||||
|
||||
@@ -1,88 +1,157 @@
|
||||
let activeDropdown = null;
|
||||
|
||||
function normalizeItems(items) {
|
||||
const value = typeof items === 'function' ? items() : items;
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function resolvePlacement({ placement, align }) {
|
||||
if (placement === 'bottom-start' || placement === 'top-start') return 'left';
|
||||
if (placement === 'bottom-end' || placement === 'top-end') return 'right';
|
||||
return align === 'left' ? 'left' : 'right';
|
||||
}
|
||||
|
||||
export function createDropdownMenu({
|
||||
anchorEl,
|
||||
items = [],
|
||||
renderContent = null,
|
||||
className = '',
|
||||
minWidth = 210,
|
||||
offset = 7,
|
||||
align = 'right',
|
||||
placement = 'bottom-end',
|
||||
align = null,
|
||||
leftShift = 0,
|
||||
transparent = false,
|
||||
dimBackground = true,
|
||||
keepAnchorPressed = true,
|
||||
onOpen = null,
|
||||
onClose = null,
|
||||
} = {}) {
|
||||
let portal = null;
|
||||
let menuEl = null;
|
||||
let destroyed = false;
|
||||
|
||||
const close = () => {
|
||||
const setAnchorOpen = (isOpen) => {
|
||||
if (!anchorEl) return;
|
||||
anchorEl.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true';
|
||||
else delete anchorEl.dataset.open;
|
||||
};
|
||||
|
||||
const close = ({ focusAnchor = false } = {}) => {
|
||||
if (!portal) return;
|
||||
portal.remove();
|
||||
portal = null;
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
menuEl = null;
|
||||
if (activeDropdown === api) activeDropdown = null;
|
||||
setAnchorOpen(false);
|
||||
onClose?.();
|
||||
if (focusAnchor) anchorEl?.focus?.();
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!portal || !anchorEl) return;
|
||||
if (!portal || !menuEl || !anchorEl) return;
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = portal.offsetWidth || minWidth;
|
||||
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const menuWidth = menuEl.offsetWidth || minWidth;
|
||||
const side = resolvePlacement({ placement, align });
|
||||
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const desiredLeft = baseLeft - Number(leftShift || 0);
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||
let top = rect.bottom + offset;
|
||||
const menuHeight = portal.offsetHeight || 180;
|
||||
if (top + menuHeight > window.innerHeight - margin) {
|
||||
|
||||
const menuHeight = menuEl.offsetHeight || 180;
|
||||
const prefersTop = String(placement || '').startsWith('top-');
|
||||
let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset;
|
||||
if (!prefersTop && top + menuHeight > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||
} else if (prefersTop && top < margin) {
|
||||
top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset);
|
||||
}
|
||||
portal.style.left = `${Math.round(left)}px`;
|
||||
portal.style.top = `${Math.round(top)}px`;
|
||||
|
||||
menuEl.style.left = `${Math.round(left)}px`;
|
||||
menuEl.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!anchorEl || portal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.style.minWidth = `${minWidth}px`;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.divider) {
|
||||
const appendItems = () => {
|
||||
normalizeItems(items).forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
menu.append(divider);
|
||||
divider.className = 'dropdown-menu__divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
menuEl.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
if (item?.iconHtml) {
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`;
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.disabled = Boolean(item.disabled);
|
||||
|
||||
if (item.iconHtml) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shared-dropdown-menu__icon';
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.innerHTML = item.iconHtml;
|
||||
btn.append(icon);
|
||||
} else if (item?.iconSrc) {
|
||||
button.append(icon);
|
||||
} else if (item.iconSrc) {
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.src = item.iconSrc;
|
||||
icon.alt = '';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon);
|
||||
button.append(icon);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(item?.label || '');
|
||||
btn.append(label);
|
||||
btn.addEventListener('click', (event) => {
|
||||
label.textContent = String(item.label || '');
|
||||
button.append(label);
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
item?.action?.();
|
||||
await item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
menuEl.append(button);
|
||||
});
|
||||
};
|
||||
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
document.body.append(menu);
|
||||
portal = menu;
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
const open = () => {
|
||||
if (destroyed || !anchorEl || portal) return;
|
||||
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
|
||||
|
||||
portal = document.createElement('div');
|
||||
portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`;
|
||||
|
||||
if (dimBackground) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dropdown-backdrop';
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === backdrop) close();
|
||||
});
|
||||
portal.append(backdrop);
|
||||
}
|
||||
|
||||
menuEl = document.createElement('div');
|
||||
menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`;
|
||||
menuEl.setAttribute('role', 'menu');
|
||||
menuEl.style.minWidth = `${minWidth}px`;
|
||||
|
||||
if (typeof renderContent === 'function') {
|
||||
const content = renderContent({ close, menuEl });
|
||||
if (content instanceof Node) menuEl.append(content);
|
||||
} else {
|
||||
appendItems();
|
||||
}
|
||||
|
||||
menuEl.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
menuEl.addEventListener('click', (event) => event.stopPropagation());
|
||||
portal.append(menuEl);
|
||||
document.body.append(portal);
|
||||
activeDropdown = api;
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
};
|
||||
@@ -97,43 +166,49 @@ export function createDropdownMenu({
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
};
|
||||
const onOutsideClick = (event) => {
|
||||
const onOutsidePointerDown = (event) => {
|
||||
if (!portal) return;
|
||||
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!portal || event?.detail?.owner === anchorEl) return;
|
||||
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close();
|
||||
anchorEl?.focus();
|
||||
close({ focusAnchor: true });
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
setAnchorOpen(false);
|
||||
anchorEl?.addEventListener('click', onAnchorClick);
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', position, { passive: true });
|
||||
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||
window.addEventListener('popstate', onNavigation);
|
||||
window.addEventListener('hashchange', onNavigation);
|
||||
window.addEventListener('resize', onViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
const api = {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
position,
|
||||
get isOpen() {
|
||||
return Boolean(portal);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
close();
|
||||
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', position);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
window.removeEventListener('popstate', onNavigation);
|
||||
window.removeEventListener('hashchange', onNavigation);
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('scroll', onViewportChange, true);
|
||||
setAnchorOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'header-left-label';
|
||||
label.textContent = leftLabel;
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
rightActions.forEach((action) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) btn.title = action.title;
|
||||
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.iconNode instanceof Node) {
|
||||
btn.append(action.iconNode);
|
||||
} else {
|
||||
btn.textContent = action.label;
|
||||
}
|
||||
btn.addEventListener('click', action.onClick);
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
@@ -72,6 +72,8 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const isProfile = item.pageId === 'profile-view';
|
||||
const isMessages = item.pageId === 'messages-list';
|
||||
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' : ''}`;
|
||||
if (isProfile) {
|
||||
btn.innerHTML = `
|
||||
@@ -97,6 +99,14 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||
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') {
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
@@ -105,5 +115,19 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
if (!target || !(node instanceof Node)) return;
|
||||
target.append(node);
|
||||
}
|
||||
|
||||
function createActionButton(action = {}, cleanupFns) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__action${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) button.title = action.title;
|
||||
if (action.ariaLabel) button.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.id) button.dataset.action = action.id;
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
|
||||
if (action.menu) {
|
||||
const menu = createDropdownMenu({
|
||||
anchorEl: button,
|
||||
transparent: true,
|
||||
dimBackground: true,
|
||||
keepAnchorPressed: true,
|
||||
...(typeof action.menu === 'object' ? action.menu : {}),
|
||||
});
|
||||
cleanupFns.add(() => menu.destroy());
|
||||
} else if (typeof action.onClick === 'function') {
|
||||
button.addEventListener('click', action.onClick);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createTopBar({
|
||||
title = '',
|
||||
className = '',
|
||||
left = null,
|
||||
center = null,
|
||||
back = null,
|
||||
leftLabel = '',
|
||||
actions = [],
|
||||
} = {}) {
|
||||
const cleanupFns = new Set();
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = `topbar${className ? ` ${className}` : ''}`;
|
||||
|
||||
const leftSlot = document.createElement('div');
|
||||
leftSlot.className = 'topbar__left';
|
||||
|
||||
const backAction = back;
|
||||
if (backAction?.visible !== false && typeof backAction?.onClick === 'function') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
leftSlot.append(button);
|
||||
}
|
||||
|
||||
appendNode(leftSlot, left);
|
||||
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'topbar__left-label';
|
||||
label.textContent = leftLabel;
|
||||
leftSlot.append(label);
|
||||
}
|
||||
|
||||
const centerSlot = document.createElement('div');
|
||||
centerSlot.className = 'topbar__center';
|
||||
const resolvedCenter = center;
|
||||
if (resolvedCenter instanceof Node) {
|
||||
centerSlot.append(resolvedCenter);
|
||||
} else {
|
||||
const heading = document.createElement('h1');
|
||||
heading.className = 'topbar__title';
|
||||
heading.textContent = String(title || '');
|
||||
centerSlot.append(heading);
|
||||
}
|
||||
|
||||
const rightSlot = document.createElement('div');
|
||||
rightSlot.className = 'topbar__right';
|
||||
const normalizedActions = actions;
|
||||
normalizedActions.forEach((action) => {
|
||||
rightSlot.append(createActionButton(action, cleanupFns));
|
||||
});
|
||||
|
||||
topbar.append(leftSlot, centerSlot, rightSlot);
|
||||
|
||||
topbar.addCleanup = (cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanupFns.add(cleanup);
|
||||
return cleanup;
|
||||
};
|
||||
topbar.cleanup = () => {
|
||||
for (const cleanup of cleanupFns) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
console.warn('[TopBar] cleanup failed', error);
|
||||
}
|
||||
}
|
||||
cleanupFns.clear();
|
||||
};
|
||||
|
||||
return topbar;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
@@ -259,7 +259,7 @@ function createPasswordModal() {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -427,7 +427,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = items.map((item) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
@${escapeHtml(item.login)}
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
|
||||
</button>`
|
||||
@@ -633,11 +633,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Сервер доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
introCard,
|
||||
listCard,
|
||||
addCard,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
@@ -78,16 +78,14 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Создание канала',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -97,7 +95,7 @@ export function render({ navigate }) {
|
||||
<div class="channel-create-avatar-side">
|
||||
<div class="channel-create-avatar-status-row">
|
||||
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
|
||||
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
<button type="button" class="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
</div>
|
||||
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
@@ -47,16 +47,14 @@ function createDebounced(fn, delayMs = 240) {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -119,7 +117,7 @@ export function render({ navigate }) {
|
||||
rows.slice(0, 8).forEach((login) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = String(login);
|
||||
btn.addEventListener('click', () => {
|
||||
selectedCanonicalLogin = String(login);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
|
||||
@@ -11,16 +11,14 @@ function formatTime(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
|
||||
|
||||
function closeUploadsMenu(controls) {
|
||||
const menu = controls?.querySelector?.('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
function setStatusBadge(node, status, label) {
|
||||
if (!node) return;
|
||||
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
|
||||
@@ -64,25 +60,10 @@ function renderTile(item, index) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack arweave-uploads-screen';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'arweave-uploads-toolbar';
|
||||
controls.innerHTML = `
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -91,7 +72,6 @@ export function render({ navigate }) {
|
||||
|
||||
const uploadFile = async () => {
|
||||
statusLine.textContent = '';
|
||||
closeUploadsMenu(controls);
|
||||
try {
|
||||
await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
@@ -134,42 +114,50 @@ export function render({ navigate }) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
label: '+',
|
||||
title: 'Добавить файл',
|
||||
ariaLabel: 'Добавить файл',
|
||||
className: 'arweave-uploads-add',
|
||||
onClick: () => { void uploadFile(); },
|
||||
},
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню',
|
||||
ariaLabel: 'Меню загрузок',
|
||||
className: 'arweave-uploads-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Добавить файл', action: () => uploadFile() },
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
action: () => {
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Справка',
|
||||
action: () => window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
|
||||
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = !menu.hidden;
|
||||
});
|
||||
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
});
|
||||
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
});
|
||||
screen.addEventListener('click', (event) => {
|
||||
if (controls.contains(event.target)) return;
|
||||
closeUploadsMenu(controls);
|
||||
});
|
||||
|
||||
screen.append(controls, statusLine, list);
|
||||
chrome?.setTopbar(topbar);
|
||||
screen.append(statusLine, list);
|
||||
renderHistory();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
@@ -63,9 +63,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = renderHeader({
|
||||
const topbar = createTopBar({
|
||||
title: 'О канале',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (window.history.length > 1) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -437,7 +437,7 @@ function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, l
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
@@ -471,6 +471,7 @@ function openBlockchainDetailsModal(details) {
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
if (!isActive()) return;
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
|
||||
@@ -495,7 +496,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
@@ -508,7 +509,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
@@ -569,9 +570,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
@@ -589,10 +592,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
@@ -602,7 +606,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
@@ -668,8 +672,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
@@ -716,7 +722,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
@@ -749,16 +755,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
@@ -798,7 +808,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -821,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'message-edited-marker';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
editedMarker.title = 'Открыть историю редактирования';
|
||||
editedMarker.addEventListener('click', (event) => {
|
||||
@@ -844,7 +854,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.className = 'ui-button channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
@@ -865,7 +875,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.className = 'ui-button deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${author}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
@@ -915,7 +925,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item thread-like-btn';
|
||||
likeButton.className = 'ui-button channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
@@ -949,7 +959,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item thread-reply-btn';
|
||||
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
@@ -962,6 +972,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
isActive: handlers.isActive,
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
@@ -969,7 +980,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'channel-action-item thread-share-btn';
|
||||
shareButton.className = 'ui-button channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
@@ -987,7 +998,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
originalButton.className = 'channel-action-item';
|
||||
originalButton.className = 'ui-button channel-action-item';
|
||||
originalButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
@@ -1009,7 +1020,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.className = 'ui-button channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
@@ -1025,13 +1036,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
raw: node,
|
||||
localNumber,
|
||||
msgSubType,
|
||||
}));
|
||||
}), { isActive: handlers.isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.className = 'ui-button channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
@@ -1086,11 +1097,12 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
|
||||
const target = pendingThreadScroll.get(routeKey);
|
||||
if (!target) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (!shouldContinue()) return;
|
||||
if (target === '__LAST_REPLY__') {
|
||||
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
@@ -1108,7 +1120,7 @@ function applyPendingScroll(screen, routeKey) {
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(doScroll, 20);
|
||||
return window.setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
@@ -1120,15 +1132,18 @@ function renderSkeleton(screen) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
let selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
let activeResolvedChannelLabel = channelDisplayName;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
let refresh = () => {};
|
||||
const refreshTimers = new Set();
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
@@ -1136,10 +1151,10 @@ export function render({ navigate, route, chrome }) {
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
@@ -1156,19 +1171,12 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
try {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
}
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (disposed) return;
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
@@ -1191,6 +1199,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const handlers = {
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
@@ -1208,12 +1217,14 @@ export function render({ navigate, route, chrome }) {
|
||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, nextReaction);
|
||||
softHaptic(10);
|
||||
rerender();
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, previousReaction || 'unliked');
|
||||
rerender();
|
||||
void refresh();
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
@@ -1222,24 +1233,27 @@ export function render({ navigate, route, chrome }) {
|
||||
onReply: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Ответ отправлен');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRating: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
if (disposed) return;
|
||||
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
|
||||
.map((row) => {
|
||||
const selectorRow = {
|
||||
@@ -1264,6 +1278,7 @@ export function render({ navigate, route, chrome }) {
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
@@ -1272,6 +1287,7 @@ export function render({ navigate, route, chrome }) {
|
||||
message: target,
|
||||
text,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
showStatus('');
|
||||
@@ -1287,6 +1303,7 @@ export function render({ navigate, route, chrome }) {
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
if (result === 'copied') showToast('Ссылка скопирована');
|
||||
if (result === 'shared') showToast('Ссылка передана');
|
||||
if (result === 'copied' || result === 'shared') softHaptic(10);
|
||||
@@ -1320,33 +1337,69 @@ export function render({ navigate, route, chrome }) {
|
||||
isChannelPost: meta?.isChannelPost === true,
|
||||
channel: selector?.channel || null,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
const clearContent = () => {
|
||||
for (const timerId of refreshTimers) window.clearTimeout(timerId);
|
||||
refreshTimers.clear();
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
if (child !== statusBox) child.remove();
|
||||
});
|
||||
};
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const clearOwnedModal = () => {
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
if (!modalRoot) return;
|
||||
if (modalRoot.querySelector([
|
||||
'#thread-blockchain-details-modal',
|
||||
'#thread-edit-modal',
|
||||
'#thread-history-modal',
|
||||
'#thread-reply-modal',
|
||||
'#thread-repost-modal',
|
||||
].join(','))) {
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
const trackTimer = (timerId) => {
|
||||
if (timerId) refreshTimers.add(timerId);
|
||||
return timerId;
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let resolvedMessage = selector.message;
|
||||
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
|
||||
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const allRows = [
|
||||
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
|
||||
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
||||
@@ -1369,6 +1422,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(ownerRaw);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
if (ownerBch) {
|
||||
channel = allRows.find((item) => (
|
||||
@@ -1383,6 +1437,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && ownerLoginFromBch) {
|
||||
try {
|
||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||
channel = ownerRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
||||
@@ -1412,6 +1467,7 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
|
||||
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
@@ -1437,6 +1493,7 @@ export function render({ navigate, route, chrome }) {
|
||||
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
}
|
||||
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
@@ -1453,10 +1510,10 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
seq += 1;
|
||||
return seq;
|
||||
localSeq += 1;
|
||||
return localSeq;
|
||||
};
|
||||
|
||||
let ancestorsWrap = null;
|
||||
@@ -1506,25 +1563,33 @@ export function render({ navigate, route, chrome }) {
|
||||
if (focusWrap) screen.append(focusWrap);
|
||||
screen.append(descendantsWrap);
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
if (!hasPendingScroll && focusWrap) {
|
||||
setTimeout(() => {
|
||||
trackTimer(window.setTimeout(() => {
|
||||
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
|
||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}, 20);
|
||||
}, 20));
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
screen.append(failed);
|
||||
}
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
clearOwnedModal();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
getMessageReactionState,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -393,22 +393,11 @@ function createChannelReadTracker({
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
|
||||
if (initialSyncRequired) {
|
||||
void authService.upsertUserSetting({
|
||||
login,
|
||||
settingType: 1,
|
||||
settingKey,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: safeMessagesCount,
|
||||
storagePwd,
|
||||
}).catch(() => {});
|
||||
persistedSeenCount = safeMessagesCount;
|
||||
if (canWrite) {
|
||||
desiredSeenCount = safeMessagesCount;
|
||||
} else {
|
||||
window.setTimeout(() => measure(), 120);
|
||||
void flush();
|
||||
}
|
||||
window.setTimeout(() => measure(), 120);
|
||||
|
||||
const cleanup = () => {
|
||||
disposed = true;
|
||||
@@ -786,7 +775,7 @@ function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, ra
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
@@ -820,6 +809,7 @@ function openBlockchainDetailsModal(details) {
|
||||
});
|
||||
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
if (!isActive()) return;
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
|
||||
@@ -835,7 +825,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
@@ -848,7 +838,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
@@ -909,9 +899,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
@@ -929,10 +921,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
@@ -942,7 +935,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
|
||||
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-status-action-modal">
|
||||
@@ -984,8 +977,10 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit(String(textEl?.value || '').trim());
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
|
||||
}
|
||||
@@ -995,7 +990,7 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
|
||||
function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rows = (Array.isArray(options) ? options : [])
|
||||
.map((item, index) => `
|
||||
@@ -1028,6 +1023,7 @@ function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
|
||||
const option = options[idx];
|
||||
if (!option) return;
|
||||
close();
|
||||
if (!isActive()) return;
|
||||
await onSelect(option);
|
||||
});
|
||||
});
|
||||
@@ -1053,8 +1049,12 @@ function flashAndScrollToMessage(messageRef) {
|
||||
if (!target) return false;
|
||||
target.classList.remove('is-focus-flash');
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
|
||||
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
|
||||
window.setTimeout(() => {
|
||||
if (target.isConnected) target.classList.add('is-focus-flash');
|
||||
}, 60);
|
||||
window.setTimeout(() => {
|
||||
if (target.isConnected) target.classList.remove('is-focus-flash');
|
||||
}, 1800);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1108,7 +1108,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
const parsed = parseMessageAttachments(post.body);
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'entrypoint-history-item';
|
||||
item.className = 'ui-button entrypoint-history-item';
|
||||
item.innerHTML = `
|
||||
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
|
||||
<span>#${escapeHtml(post.localNumber || '—')}</span>
|
||||
@@ -1127,7 +1127,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
});
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
@@ -1191,8 +1191,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
@@ -1201,7 +1203,7 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
@@ -1269,9 +1271,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
text: composeMessageWithAttachments(body, attachments),
|
||||
msgSubType,
|
||||
});
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||
}
|
||||
@@ -1289,10 +1293,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
@@ -1338,7 +1343,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-message-modal">
|
||||
@@ -1372,16 +1377,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
@@ -1737,7 +1746,7 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(doScroll, 20);
|
||||
return window.setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
@@ -1793,6 +1802,7 @@ function renderPostCard(post, {
|
||||
onRepost,
|
||||
onShare,
|
||||
onEdit,
|
||||
isActive = () => true,
|
||||
}) {
|
||||
const versionsTotal = Number(post?.versionsTotal || 1);
|
||||
|
||||
@@ -1803,7 +1813,7 @@ function renderPostCard(post, {
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
|
||||
const avatar = createMessageAvatar(post.authorLogin);
|
||||
|
||||
@@ -1832,7 +1842,7 @@ function renderPostCard(post, {
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'message-edited-marker';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
editedMarker.title = 'Открыть историю редактирования';
|
||||
editedMarker.addEventListener('click', (event) => {
|
||||
@@ -1852,7 +1862,7 @@ function renderPostCard(post, {
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.className = 'ui-button channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
@@ -1886,7 +1896,7 @@ function renderPostCard(post, {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.className = 'ui-button deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
@@ -1957,7 +1967,7 @@ function renderPostCard(post, {
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item channel-action-like';
|
||||
likeButton.className = 'ui-button channel-action-item channel-action-like';
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
@@ -1983,7 +1993,7 @@ function renderPostCard(post, {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item channel-action-reply';
|
||||
replyButton.className = 'ui-button channel-action-item channel-action-reply';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
@@ -1996,6 +2006,7 @@ function renderPostCard(post, {
|
||||
openReplyModal({
|
||||
navigate,
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
isActive,
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
@@ -2005,7 +2016,7 @@ function renderPostCard(post, {
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'channel-action-item channel-action-share';
|
||||
shareButton.className = 'ui-button channel-action-item channel-action-share';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
@@ -2022,7 +2033,7 @@ function renderPostCard(post, {
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
originalBtn.className = 'channel-action-item';
|
||||
originalBtn.className = 'ui-button channel-action-item';
|
||||
originalBtn.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
@@ -2044,7 +2055,7 @@ function renderPostCard(post, {
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.className = 'ui-button channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
@@ -2060,13 +2071,13 @@ function renderPostCard(post, {
|
||||
raw: post.rawMessage,
|
||||
localNumber: post.localNumber,
|
||||
msgSubType: post.msgSubType,
|
||||
}));
|
||||
}), { isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.className = 'ui-button channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
@@ -2166,6 +2177,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
onRepost: handlers.onRepost,
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
isActive: handlers.isActive,
|
||||
});
|
||||
const key = messageRefKey(item.post.messageRef);
|
||||
if (key) {
|
||||
@@ -2203,10 +2215,10 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
|
||||
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
|
||||
}
|
||||
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
|
||||
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
||||
: 0;
|
||||
|
||||
const tracker = createChannelReadTracker({
|
||||
screen,
|
||||
@@ -2220,7 +2232,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
initialSeenCount: readCount,
|
||||
});
|
||||
|
||||
return tracker.cleanup;
|
||||
return () => {
|
||||
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
|
||||
if (unreadScrollTimer) window.clearTimeout(unreadScrollTimer);
|
||||
tracker.cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
@@ -2238,14 +2254,22 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
let refresh = () => {};
|
||||
let cleanupSeenTracking = null;
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран канала уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (disposed) return;
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
@@ -2255,33 +2279,79 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
let activeChannelData = null;
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
centerNode: channelHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
const header = createTopBar({
|
||||
center: channelHeaderButton,
|
||||
back: { onClick: () => navigate('channels-list') },
|
||||
className: 'channel-view-topbar',
|
||||
actions: [
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
{
|
||||
label: '⋯',
|
||||
title: 'Действия канала',
|
||||
ariaLabel: 'Открыть меню канала',
|
||||
className: 'channel-header-more-btn',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: () => {
|
||||
const apiData = activeChannelData;
|
||||
if (!apiData) return [];
|
||||
const aboutRoute = makeShineChannelAboutRoute({
|
||||
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
|
||||
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
|
||||
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
|
||||
});
|
||||
const items = [
|
||||
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
|
||||
];
|
||||
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
||||
items.push({
|
||||
label: 'Отписаться от канала',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||
unfollow: true,
|
||||
});
|
||||
if (disposed) return;
|
||||
const feed = await authService.listSubscriptionsFeed(login, 200);
|
||||
if (disposed) return;
|
||||
setChannelsFeed(feed, state.channelsIndex);
|
||||
showToast('Вы отписались от канала');
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
const channelEntrypointButton = header.querySelector('.topbar__right .channel-header-entrypoint-btn');
|
||||
const channelMoreButton = header.querySelector('.topbar__right .channel-header-more-btn');
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
}
|
||||
chrome?.setTopbar(header);
|
||||
|
||||
const rerender = () => {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
};
|
||||
let activeSelector = null;
|
||||
|
||||
const requireSigningSession = () => {
|
||||
@@ -2313,12 +2383,14 @@ export function render({ navigate, route, chrome }) {
|
||||
} else {
|
||||
await authService.addBlockLike({ login, storagePwd, message: messageRef });
|
||||
}
|
||||
if (disposed) return;
|
||||
setMessageReactionState(messageRef, nextReaction);
|
||||
softHaptic(10);
|
||||
rerender();
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
setMessageReactionState(messageRef, previousReaction || 'unliked');
|
||||
rerender();
|
||||
void refresh();
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
@@ -2328,25 +2400,27 @@ export function render({ navigate, route, chrome }) {
|
||||
const onReply = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
|
||||
ensureActive();
|
||||
|
||||
const scrollTarget = messageRefKey(messageRef);
|
||||
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||||
|
||||
softHaptic(15);
|
||||
showToast('Ответ отправлен');
|
||||
rerender();
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const onRating = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
|
||||
ensureActive();
|
||||
|
||||
const scrollTarget = messageRefKey(messageRef);
|
||||
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||||
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
rerender();
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const onStatusAction = async (post) => {
|
||||
@@ -2356,10 +2430,12 @@ export function render({ navigate, route, chrome }) {
|
||||
openStatusActionMenuModal({
|
||||
targetLabel: typeMeta.label,
|
||||
options,
|
||||
isActive: () => !disposed,
|
||||
onSelect: async (option) => {
|
||||
openStatusActionCommentModal({
|
||||
title: option.modalTitle,
|
||||
submitLabel: option.label,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async (text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockStatusAction({
|
||||
@@ -2369,9 +2445,10 @@ export function render({ navigate, route, chrome }) {
|
||||
text,
|
||||
statusSubType: option.subType,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(14);
|
||||
showToast(`${option.label} сохранено`);
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -2411,10 +2488,12 @@ export function render({ navigate, route, chrome }) {
|
||||
const onRepost = async (messageRef) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const channels = await loadOwnedChannelsForRepost(login);
|
||||
if (disposed) return;
|
||||
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
@@ -2423,9 +2502,10 @@ export function render({ navigate, route, chrome }) {
|
||||
message: messageRef,
|
||||
text,
|
||||
});
|
||||
ensureActive();
|
||||
if (isSameChannelSelector(channel, activeSelector)) {
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
rerender();
|
||||
void refresh();
|
||||
}
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
@@ -2442,6 +2522,7 @@ export function render({ navigate, route, chrome }) {
|
||||
text: 'Тред из канала SHiNE',
|
||||
url: buildAbsoluteRouteUrl(routeToShare),
|
||||
});
|
||||
if (disposed) return;
|
||||
if (result === 'copied') showToast('Ссылка скопирована');
|
||||
if (result === 'shared') showToast('Ссылка передана');
|
||||
if (result === 'shared' || result === 'copied') softHaptic(10);
|
||||
@@ -2463,11 +2544,12 @@ export function render({ navigate, route, chrome }) {
|
||||
text: bodyText,
|
||||
msgSubType,
|
||||
});
|
||||
ensureActive();
|
||||
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
softHaptic(15);
|
||||
showToast('Сообщение отправлено');
|
||||
rerender();
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const onEditPost = async (messageRef, text) => {
|
||||
@@ -2484,9 +2566,10 @@ export function render({ navigate, route, chrome }) {
|
||||
isChannelPost: !isDiaryEdit,
|
||||
channel: isDiaryEdit ? null : activeSelector,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
rerender();
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const onEditChannelMeta = async ({ title, description, avatar }) => {
|
||||
@@ -2502,21 +2585,65 @@ export function render({ navigate, route, chrome }) {
|
||||
description,
|
||||
avatar,
|
||||
});
|
||||
ensureActive();
|
||||
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
|
||||
softHaptic(12);
|
||||
showToast('Профиль канала обновлён');
|
||||
rerender();
|
||||
void refresh();
|
||||
};
|
||||
|
||||
screen.append(statusBox);
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const clearContent = () => {
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
cleanupSeenTracking = null;
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
if (child !== statusBox) child.remove();
|
||||
});
|
||||
};
|
||||
|
||||
let cleanupSeenTracking = null;
|
||||
const clearOwnedModal = () => {
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
if (!modalRoot) return;
|
||||
const ownedSelector = [
|
||||
'#about-channel-modal',
|
||||
'#blockchain-details-modal',
|
||||
'#channel-entrypoint-history-modal',
|
||||
'#channel-entrypoint-menu-modal',
|
||||
'#channel-message-modal',
|
||||
'#channel-status-action-modal',
|
||||
'#channel-status-menu-modal',
|
||||
'#edit-channel-modal',
|
||||
'#edit-message-modal',
|
||||
'#message-history-modal',
|
||||
'#reply-modal',
|
||||
'#repost-modal',
|
||||
].join(',');
|
||||
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
activeChannelData = null;
|
||||
activeSelector = null;
|
||||
showStatus('');
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.disabled = true;
|
||||
channelHeaderButton.onclick = null;
|
||||
if (channelMoreButton) channelMoreButton.disabled = true;
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
channelEntrypointButton.onclick = null;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
activeSelector = apiData?.selector || null;
|
||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
||||
@@ -2544,6 +2671,10 @@ export function render({ navigate, route, chrome }) {
|
||||
if (aboutRoute) navigate(aboutRoute);
|
||||
};
|
||||
}
|
||||
activeChannelData = apiData;
|
||||
if (channelMoreButton) {
|
||||
channelMoreButton.disabled = false;
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||
@@ -2557,10 +2688,12 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
skeleton.remove();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
isActive: () => !disposed,
|
||||
onAddMessage: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
@@ -2650,31 +2783,41 @@ export function render({ navigate, route, chrome }) {
|
||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||
unfollow: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
|
||||
const feed = await authService.listSubscriptionsFeed(login, 200);
|
||||
if (disposed) return;
|
||||
setChannelsFeed(feed, state.channelsIndex);
|
||||
softHaptic(15);
|
||||
showToast('Подписка на канал выполнена');
|
||||
rerender();
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
if (isChannelsDemoMode()) {
|
||||
renderDemoFallback(screen, navigate, error);
|
||||
return;
|
||||
}
|
||||
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
||||
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), () => {
|
||||
void refresh();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
};
|
||||
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
clearOwnedModal();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -16,17 +16,17 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const DIARY_CHANNEL_NAME = 'diary';
|
||||
const DIARY_DISPLAY_NAME = 'Дневник';
|
||||
const CHANNEL_READ_SETTING_TYPE = 1;
|
||||
|
||||
const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
@@ -111,6 +111,58 @@ function isVisibleChannelSummary(summary) {
|
||||
return !!ownerLogin && !!channelName;
|
||||
}
|
||||
|
||||
function channelReadSettingKey(summary) {
|
||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||
if (!ownerBch || !channelName) return '';
|
||||
return `${ownerBch}/${channelName}`;
|
||||
}
|
||||
|
||||
async function ensureChannelReadBaselines(feed) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) return;
|
||||
|
||||
let settingsPayload;
|
||||
try {
|
||||
settingsPayload = await authService.listUserSettings(login);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = new Set(
|
||||
(Array.isArray(settingsPayload?.settings) ? settingsPayload.settings : [])
|
||||
.filter((item) => Number(item?.setting_type) === CHANNEL_READ_SETTING_TYPE)
|
||||
.map((item) => String(item?.setting_key || '').trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const summaries = [
|
||||
...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []),
|
||||
...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []),
|
||||
].filter(isVisibleChannelSummary);
|
||||
|
||||
for (const summary of summaries) {
|
||||
const settingKey = channelReadSettingKey(summary);
|
||||
if (!settingKey || existing.has(settingKey)) continue;
|
||||
|
||||
try {
|
||||
await authService.upsertUserSetting({
|
||||
login,
|
||||
settingType: CHANNEL_READ_SETTING_TYPE,
|
||||
settingKey,
|
||||
timeMs: Date.now(),
|
||||
valueText: '',
|
||||
valueNum: Math.max(0, Number(summary?.messagesCount || 0)),
|
||||
storagePwd,
|
||||
});
|
||||
existing.add(settingKey);
|
||||
} catch {
|
||||
// Не ломаем экран каналов из-за фоновой инициализации read-state.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function avatarLetterFromName(name = '') {
|
||||
const first = Array.from(String(name || '').trim())[0] || '#';
|
||||
return first.toUpperCase();
|
||||
@@ -258,7 +310,7 @@ function renderSuggestions(container, values, onPick) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -528,7 +580,7 @@ function openChannelFinderModal({ navigate }) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value.label;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -945,252 +997,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function closeChannelMenu(listState, clearOpenMenuId = true) {
|
||||
if (typeof listState.menuCleanup === 'function') {
|
||||
listState.menuCleanup();
|
||||
}
|
||||
listState.menuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
if (clearOpenMenuId) {
|
||||
listState.openMenuId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeTopChannelsMenu(listState) {
|
||||
if (typeof listState.topMenuCleanup === 'function') {
|
||||
listState.topMenuCleanup();
|
||||
}
|
||||
listState.topMenuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 250;
|
||||
const titleAnchor = document.querySelector('.channels-filter-title');
|
||||
const titleRect = titleAnchor?.getBoundingClientRect?.();
|
||||
let top = (titleRect?.bottom || rect.bottom) + 7;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = TOP_MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
divider.style.margin = '6px 0';
|
||||
menu.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-menu-item';
|
||||
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
closeTopChannelsMenu(listState);
|
||||
item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
});
|
||||
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (event?.detail?.owner === anchorEl) return;
|
||||
closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
listState.topMenuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
};
|
||||
}
|
||||
|
||||
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||||
closeChannelMenu(listState, false);
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 210;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const canToggleSubscription = !channel.isOwnChannel;
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||
|
||||
const actionLabel = document.createElement('span');
|
||||
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||
|
||||
if (canToggleSubscription) {
|
||||
actionLabel.textContent = channel.pending
|
||||
? 'Выполняется...'
|
||||
: channel.isSubscribed
|
||||
? 'Отписаться'
|
||||
: 'Подписаться';
|
||||
actionBtn.disabled = !!channel.pending;
|
||||
|
||||
actionBtn.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
if (channel.pending) return;
|
||||
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
channel.pending = true;
|
||||
actionBtn.disabled = true;
|
||||
actionLabel.textContent = 'Выполняется...';
|
||||
|
||||
const nextSubscribed = !channel.isSubscribed;
|
||||
try {
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: channel.ownerBlockchainName,
|
||||
targetBlockNumber: channel.channelRootBlockNumber,
|
||||
targetBlockHashHex: channel.channelRootBlockHash,
|
||||
unfollow: !nextSubscribed,
|
||||
});
|
||||
|
||||
channel.isSubscribed = nextSubscribed;
|
||||
channel.pending = false;
|
||||
softHaptic(15);
|
||||
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
|
||||
closeChannelMenu(listState);
|
||||
await refreshFeed();
|
||||
} catch (error) {
|
||||
channel.pending = false;
|
||||
actionBtn.disabled = false;
|
||||
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||
rerenderList();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionLabel.textContent = 'Собственный канал';
|
||||
actionBtn.disabled = true;
|
||||
}
|
||||
|
||||
const toggleWrap = document.createElement('div');
|
||||
toggleWrap.className = 'channel-menu-toggle';
|
||||
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.className = 'channel-menu-toggle-label';
|
||||
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
|
||||
toggleBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(toggleBtn);
|
||||
|
||||
channel.notificationsEnabled = !channel.notificationsEnabled;
|
||||
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
|
||||
listState.notificationsState = next;
|
||||
writeChannelNotificationsState(next);
|
||||
|
||||
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
|
||||
softHaptic(10);
|
||||
});
|
||||
|
||||
toggleWrap.append(toggleLabel, toggleBtn);
|
||||
menu.append(actionBtn, toggleWrap);
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowResize = () => {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
listState.menuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMain(channel) {
|
||||
const main = document.createElement('div');
|
||||
main.className = 'channel-row-main';
|
||||
@@ -1297,7 +1103,6 @@ function updateBottomCta({ button }) {
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
@@ -1323,6 +1128,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
void ensureChannelReadBaselines(feed);
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
@@ -1357,39 +1163,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const createSuccessFlash = pullCreateSuccessFlash();
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
menuCleanup: null,
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
align: 'left',
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
@@ -1399,28 +1195,25 @@ export function render({ navigate, route, chrome }) {
|
||||
],
|
||||
});
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
|
||||
const topMenuBtn = document.createElement('button');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
});
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
@@ -1429,9 +1222,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const rerenderList = () => {
|
||||
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
|
||||
|
||||
renderListContent({
|
||||
screen,
|
||||
container: contentEl,
|
||||
@@ -1442,7 +1233,6 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
@@ -1462,10 +1252,7 @@ export function render({ navigate, route, chrome }) {
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
channelsFilterMenu.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
+122
-200
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
@@ -32,7 +33,18 @@ import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } f
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
bottomFadeAnchor: 'composer',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: true,
|
||||
scrollContainer: 'nested',
|
||||
},
|
||||
};
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function menuIconSvg(name) {
|
||||
@@ -49,58 +61,6 @@ function menuIconSvg(name) {
|
||||
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
|
||||
<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">
|
||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||
<span>Показать связи</span>
|
||||
</button>
|
||||
<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" />
|
||||
<span>Показать профиль</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const layer = root.querySelector('#chat-user-menu-layer');
|
||||
const menu = root.querySelector('.dm-user-identity-menu');
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!menu) return;
|
||||
const width = menu.offsetWidth || 190;
|
||||
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
});
|
||||
|
||||
layer?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === layer) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChatRelationType(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
||||
@@ -178,15 +138,30 @@ function createChatHeaderParts(login, navigate) {
|
||||
};
|
||||
|
||||
renderPeer();
|
||||
identityButton.addEventListener('click', (event) => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||
const identityMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: identityButton,
|
||||
placement: 'bottom-start',
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{
|
||||
label: 'Показать связи',
|
||||
iconSrc: '/assets/SHiNE_connections_blue.svg',
|
||||
action: () => navigate(makeProfileLinksRoute(cleanLogin)),
|
||||
},
|
||||
{
|
||||
label: 'Показать профиль',
|
||||
iconSrc: '/assets/profile-icon-profile.svg',
|
||||
action: () => navigate(makeProfileRoute(cleanLogin)),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
centerNode: identityButton,
|
||||
center: identityButton,
|
||||
updatePeer,
|
||||
getPeer: () => ({ ...currentPeer }),
|
||||
cleanup: () => identityMenu.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,18 +239,30 @@ function openChatConfirmModal({
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
||||
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
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 = `
|
||||
<div class="modal" id="chat-delete-chat-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">Удалить чат?</h3>
|
||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
||||
<label class="dm-confirm-check">
|
||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||
<span>Также удалить всю историю переписки</span>
|
||||
</label>
|
||||
${isProtectedRelation ? `
|
||||
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
|
||||
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
|
||||
` : `
|
||||
<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">
|
||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||
@@ -290,10 +277,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
||||
|
||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||
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();
|
||||
if (typeof onConfirm === 'function') {
|
||||
await onConfirm({ deleteHistory });
|
||||
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -400,91 +389,6 @@ function openMessageActionsMenu({
|
||||
});
|
||||
}
|
||||
|
||||
function openChatActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
showAddContact = false,
|
||||
onCall,
|
||||
onVideoCall,
|
||||
onAddContact,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
|
||||
const menuId = `chat-header-actions-menu-${Date.now()}`;
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<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-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 dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = root.querySelector(`#${menuId}`);
|
||||
if (!menu) return;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (menu.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
const left = Math.min(
|
||||
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
|
||||
Math.max(12, viewportWidth - menuRect.width - 12)
|
||||
);
|
||||
const top = Math.min(
|
||||
Math.max(12, Number(anchorY || 0) + 10),
|
||||
Math.max(12, viewportHeight - menuRect.height - 12)
|
||||
);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
|
||||
menu.classList.add('is-visible');
|
||||
});
|
||||
|
||||
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onCall === 'function') await onCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
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 () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
});
|
||||
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onDeleteChat === 'function') await onDeleteChat();
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -839,7 +743,7 @@ function renderLog(
|
||||
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
|
||||
const replyBox = document.createElement('button');
|
||||
replyBox.type = 'button';
|
||||
replyBox.className = 'bubble-reply-preview';
|
||||
replyBox.className = 'ui-button bubble-reply-preview';
|
||||
|
||||
const replyAuthor = document.createElement('div');
|
||||
replyAuthor.className = 'bubble-reply-preview-author';
|
||||
@@ -984,7 +888,6 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
document.body.classList.add('chat-topbar-overlay');
|
||||
const routeChatId = route.params.chatId || 'u1';
|
||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
||||
@@ -1126,10 +1029,10 @@ export function render({ navigate, route, chrome }) {
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
const chatHeader = createTopBar({
|
||||
center: chatHeaderParts.center,
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
actions: [
|
||||
{
|
||||
title: 'Позвонить',
|
||||
ariaLabel: 'Позвонить',
|
||||
@@ -1142,22 +1045,28 @@ export function render({ navigate, route, chrome }) {
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
onClick: (event) => {
|
||||
openChatActionsMenu({
|
||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
|
||||
onCall: () => handleStartCall('audio'),
|
||||
onVideoCall: () => handleStartCall('video'),
|
||||
onAddContact: async () => {
|
||||
try {
|
||||
await addPeerToContacts();
|
||||
} catch (error) {
|
||||
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
menu: {
|
||||
minWidth: 230,
|
||||
items: () => [
|
||||
{ label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') },
|
||||
{ label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') },
|
||||
normalizeChatRelationType(peerRelationType) === 'none'
|
||||
? {
|
||||
label: 'Добавить в контакты',
|
||||
iconHtml: '<span aria-hidden="true">+</span>',
|
||||
action: async () => {
|
||||
try {
|
||||
await addPeerToContacts();
|
||||
} catch (error) {
|
||||
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
: null,
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
iconHtml: menuIconSvg('clear'),
|
||||
action: () => openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
|
||||
confirmLabel: 'Очистить',
|
||||
@@ -1170,39 +1079,54 @@ export function render({ navigate, route, chrome }) {
|
||||
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
onDeleteChat: async () => {
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) {
|
||||
await clearConversationHistory();
|
||||
{
|
||||
label: 'Удалить чат',
|
||||
iconHtml: menuIconSvg('delete'),
|
||||
danger: true,
|
||||
action: () => {
|
||||
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
relationType: relationBeforeDelete,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) await clearConversationHistory();
|
||||
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||
? ['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();
|
||||
setContacts(
|
||||
contactsPayload?.contacts
|
||||
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|
||||
|| [],
|
||||
);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Чат удалён', { timeoutMs: 1200 });
|
||||
navigate('messages-list');
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind: 'contact',
|
||||
enabled: false,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(contactsPayload?.contacts || []);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
||||
navigate('messages-list');
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
chatHeader.addCleanup(chatHeaderParts.cleanup);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
|
||||
@@ -1758,8 +1682,6 @@ export function render({ navigate, route, chrome }) {
|
||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
chrome?.setComposer(null);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
@@ -148,6 +146,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(card, helpModal);
|
||||
screen.append(card);
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
modalRoot?.append(helpModal);
|
||||
screen.cleanup = () => {
|
||||
helpModal.remove();
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -58,7 +58,7 @@ function createSearchAvatar(login) {
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-search-screen';
|
||||
let searchTimer = 0;
|
||||
@@ -172,11 +172,11 @@ export function render({ navigate }) {
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Поиск контактов',
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}));
|
||||
screen.append(
|
||||
formCard,
|
||||
resultsCard,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, state } from '../state.js';
|
||||
import {
|
||||
isClientErrorReportingEnabled,
|
||||
@@ -246,16 +246,14 @@ function openUiErrorReportingModal() {
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки разработчика',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
|
||||
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить через камеру',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'camera-shell';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -165,7 +165,7 @@ function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
let savedKeys = null;
|
||||
@@ -178,12 +178,10 @@ export function render({ navigate }) {
|
||||
let dialogMode = '';
|
||||
let pendingTransferRequest = null;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить по коду',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const transferDialog = document.createElement('div');
|
||||
transferDialog.className = 'pairing-transfer-dialog';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import {
|
||||
@@ -9,16 +9,14 @@ import {
|
||||
|
||||
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать QR-код',
|
||||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('connect-device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack qr-card';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
isSessionInvalidError,
|
||||
@@ -31,19 +31,17 @@ function formatOnlineStatus(onlineOnThisServer) {
|
||||
return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server';
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({navigate, route, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const sessionId = route?.params?.sessionId || '';
|
||||
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Сеанс устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
if (!session) {
|
||||
const empty = document.createElement('div');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
@@ -40,16 +40,14 @@ function sortSessionsByOnline(sessions = []) {
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Устройства',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
@@ -75,7 +73,7 @@ export function render({ navigate }) {
|
||||
|
||||
const createSessionItem = (session, isCurrent) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false };
|
||||
@@ -181,7 +181,7 @@ export function render() {
|
||||
})();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'DEVNET пополнение',
|
||||
}),
|
||||
senderBox,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authorizeLocalDemoSession,
|
||||
isLocalDemoAvailable,
|
||||
@@ -173,9 +173,9 @@ export function render({ navigate }) {
|
||||
actions.append(serverUiButton, cancelButton, saveButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Настройки входа',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
back: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
body,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authorizeSession, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
|
||||
@@ -91,9 +91,9 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Какие ключи сохранить',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
|
||||
back: { label: '←', onClick: () => navigate('login-password-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { saveEntryLanguage, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||
@@ -8,21 +8,19 @@ function resolveReturnPage() {
|
||||
return stored === 'start-view' ? 'start-view' : 'settings-view';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack language-screen';
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Язык / Language',
|
||||
leftAction: { label: '←', onClick: () => {
|
||||
back: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack language-choice-card';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
consumeAuthReturnPage,
|
||||
isAddingProfileLogin,
|
||||
clearAuthMessages,
|
||||
clearBrowserClientData,
|
||||
refreshSessions,
|
||||
@@ -205,10 +207,13 @@ export function render({ navigate }) {
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(session.login).catch(() => {});
|
||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||
@@ -221,7 +226,7 @@ export function render({ navigate }) {
|
||||
state.loginDraft.password = '';
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
||||
setAuthError(message);
|
||||
@@ -239,9 +244,9 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Войти по QR-коду',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
stopCamera();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
consumeAuthReturnPage,
|
||||
isAddingProfileLogin,
|
||||
clearAuthMessages,
|
||||
clearBrowserClientData,
|
||||
refreshSessions,
|
||||
@@ -182,10 +184,13 @@ export function render({ navigate }) {
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(session.login).catch(() => {});
|
||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||
@@ -199,7 +204,7 @@ export function render({ navigate }) {
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
||||
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
};
|
||||
|
||||
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
||||
@@ -215,10 +220,13 @@ export function render({ navigate }) {
|
||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||
};
|
||||
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
const addingProfile = isAddingProfileLogin();
|
||||
if (!addingProfile) {
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
}
|
||||
await clearStoredMessages(login).catch(() => {});
|
||||
await authService.persistSessionMaterial(login, sessionMaterial);
|
||||
const resumed = await authService.resumeSession(login, sessionId);
|
||||
authorizeSession({
|
||||
@@ -231,7 +239,7 @@ export function render({ navigate }) {
|
||||
await refreshSessions();
|
||||
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
||||
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
||||
navigate('profile-view');
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
};
|
||||
|
||||
const schedulePoll = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
@@ -170,9 +170,9 @@ export function render({ navigate }) {
|
||||
panel.append(title, passwordField, status, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
back: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
panel,
|
||||
overlay,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
cancelAddProfileLogin,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
@@ -153,9 +154,19 @@ export function render({ navigate }) {
|
||||
panel.append(title, loginField, status, remoteWrap, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
navigate('start-view');
|
||||
},
|
||||
},
|
||||
}),
|
||||
panel,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
@@ -200,34 +201,19 @@ export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-list-screen';
|
||||
const head = document.createElement('header');
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand">
|
||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||
</div>
|
||||
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div class="dm-head-menu" role="menu" hidden>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
head.querySelector('.dm-head-logo-wrap')?.append(
|
||||
createShineConnectionsLogo({ className: 'dm-head-logo' }),
|
||||
);
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'dm-head-brand';
|
||||
const logoWrap = document.createElement('span');
|
||||
logoWrap.className = 'dm-head-logo-wrap';
|
||||
logoWrap.setAttribute('aria-hidden', 'true');
|
||||
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
|
||||
brand.append(logoWrap);
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
||||
const filterTitle = document.createElement('button');
|
||||
filterTitle.type = 'button';
|
||||
filterTitle.className = 'dm-head-filter-title';
|
||||
filterTitle.textContent = 'Чаты';
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
@@ -237,8 +223,9 @@ export function render({ navigate, chrome }) {
|
||||
};
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: filterTitle,
|
||||
align: 'left',
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
@@ -250,92 +237,31 @@ export function render({ navigate, chrome }) {
|
||||
],
|
||||
});
|
||||
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||
// land on the content layer underneath. Render the open menu as a body portal.
|
||||
menuTemplate?.remove();
|
||||
|
||||
let menuPortal = null;
|
||||
|
||||
const closeHeadMenu = () => {
|
||||
menuPortal?.remove();
|
||||
menuPortal = null;
|
||||
menuButton?.setAttribute('aria-expanded', 'false');
|
||||
menuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionHeadMenu = () => {
|
||||
if (!menuPortal || !menuButton) return;
|
||||
const rect = menuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
const titleRect = filterTitle?.getBoundingClientRect?.();
|
||||
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
if (!menuButton || menuPortal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5"></circle>
|
||||
<path d="M16 16l4 4"></path>
|
||||
</svg>
|
||||
<span>Поиск пользователей</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeHeadMenu();
|
||||
navigate('contact-search-view');
|
||||
});
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
menuPortal = portal;
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
menuWrap?.classList.add('is-open');
|
||||
positionHeadMenu();
|
||||
};
|
||||
|
||||
menuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (menuPortal) closeHeadMenu();
|
||||
else openHeadMenu();
|
||||
const 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 head = createTopBar({
|
||||
left: brand,
|
||||
center: filterTitle,
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onOutsideClick = (event) => {
|
||||
if (!menuPortal) return;
|
||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !menuPortal) return;
|
||||
closeHeadMenu();
|
||||
menuButton?.focus();
|
||||
};
|
||||
const onMenuViewportChange = () => positionHeadMenu();
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onMenuKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack dm-list';
|
||||
|
||||
@@ -448,6 +374,9 @@ function renderRow(item) {
|
||||
});
|
||||
|
||||
const rows = Array.from(byPeer.values())
|
||||
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
|
||||
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
|
||||
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
|
||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||
.sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
@@ -519,13 +448,7 @@ function renderRow(item) {
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
chatFilterMenu.destroy();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
@@ -7,7 +8,18 @@ import { engineModelFromGraphModel } from './network/adapter.js';
|
||||
import { openNodeMenu } from './network/node-menu.js';
|
||||
import { userDisplayName } from '../services/user-display.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
export const pageMeta = {
|
||||
id: 'network-view',
|
||||
title: 'Связи',
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
bottomFadeAnchor: 'toolbar',
|
||||
fadeProfile: 'edge',
|
||||
contentUnderTopbar: true,
|
||||
scrollContainer: 'locked',
|
||||
},
|
||||
};
|
||||
|
||||
const GENDER_MALE = 'male';
|
||||
const GENDER_FEMALE = 'female';
|
||||
@@ -25,32 +37,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) {
|
||||
return normalizeLogin(value).toLowerCase();
|
||||
}
|
||||
@@ -226,8 +212,6 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'network-screen';
|
||||
const appScreenEl = document.getElementById('app-screen');
|
||||
appScreenEl?.classList.add('network-scroll-lock');
|
||||
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'network-stage';
|
||||
@@ -290,7 +274,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
<div class="modal" id="network-search-modal">
|
||||
<div class="modal-card stack">
|
||||
<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;">
|
||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||
@@ -458,15 +442,26 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const header = renderHeader({
|
||||
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 header = createTopBar({
|
||||
title: 'Связи',
|
||||
rightActions: [
|
||||
actions: [
|
||||
{
|
||||
iconNode: createHeaderSearchIcon(),
|
||||
title: 'Найти пользователя',
|
||||
ariaLabel: 'Найти пользователя',
|
||||
className: 'chat-header-icon-btn',
|
||||
onClick: openSearchModal,
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню связей',
|
||||
ariaLabel: 'Открыть меню связей',
|
||||
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -475,7 +470,6 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
screen.cleanup = () => {
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
if (routeLogin) {
|
||||
|
||||
@@ -534,7 +534,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
|
||||
if (dotOnly) {
|
||||
el.className = [
|
||||
'fg-node', 'fg-dot',
|
||||
'ui-button', 'fg-node', 'fg-dot',
|
||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
||||
src.shining ? 'is-shine' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
@@ -546,7 +546,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return el;
|
||||
}
|
||||
el.className = [
|
||||
'fg-node',
|
||||
'ui-button', 'fg-node',
|
||||
isFocus ? 'is-focus' : '',
|
||||
src.shining ? 'is-shine' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
@@ -628,8 +628,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
|
||||
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||
node.el.className = spec.dotOnly
|
||||
? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
|
||||
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// --- Рендер ----------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ export function openNodeMenu({ login, displayName = '', relationType, point, act
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const itemsHtml = actions
|
||||
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
|
||||
.map((a, i) => `<button class="ui-button fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
const CONNECTION_CLOSE_FRIEND = 10;
|
||||
const 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 profileSnapshotPending = new Map();
|
||||
|
||||
function connectionTypeLabel(typeCode) {
|
||||
function connectionActionLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND:
|
||||
return 'близкие друзья';
|
||||
default:
|
||||
return 'новую связь';
|
||||
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
||||
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
||||
case CONNECTION_FRIEND: 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: 'Уведомления' };
|
||||
|
||||
function normalizeItem(item) {
|
||||
@@ -136,12 +155,10 @@ function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events'
|
||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
@@ -239,7 +256,7 @@ function renderEngagement(engagement) {
|
||||
}
|
||||
|
||||
function notificationRoute(item, activeTab) {
|
||||
if (activeTab === 'events') {
|
||||
if (activeTab === 'events' || activeTab === 'connections') {
|
||||
const login = String(item?.sourceLogin || '').trim();
|
||||
return login ? makeProfileRoute(login) : '';
|
||||
}
|
||||
@@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) {
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'events') {
|
||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||
if (activeTab === 'connections') {
|
||||
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||
} else if (activeTab === 'events') {
|
||||
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
@@ -301,94 +320,102 @@ function renderItem(item, activeTab, navigate) {
|
||||
export function render({ navigate, chrome } = {}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack notifications-screen';
|
||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.innerHTML = `
|
||||
<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 tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
|
||||
let payloadCache = null;
|
||||
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() {
|
||||
const seq = ++requestSeq;
|
||||
const activeTab = state.notificationsTab;
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
function countsFromPayload(payload) {
|
||||
return {
|
||||
replies: Number(payload?.repliesUnseenCount || 0),
|
||||
connections: Number(payload?.connectionsUnseenCount || 0),
|
||||
events: Number(payload?.eventsUnseenCount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await authService.getNotifications(50);
|
||||
if (seq !== requestSeq) return;
|
||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
||||
.map(normalizeItem);
|
||||
if (!baseItems.length) {
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
return;
|
||||
}
|
||||
function updateToolbarBadge(payload) {
|
||||
const c = countsFromPayload(payload);
|
||||
state.notificationUnreadTotal = c.replies + c.connections + c.events;
|
||||
const btn = document.querySelector('[data-toolbar-page="notifications-view"]');
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||
if (state.notificationUnreadTotal <= 0) { badge?.remove(); 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)));
|
||||
if (seq !== requestSeq) return;
|
||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось загрузить уведомления';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
||||
card.append(title, text);
|
||||
list.replaceChildren(card);
|
||||
function renderTabs(payload) {
|
||||
const counts = countsFromPayload(payload);
|
||||
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||
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');
|
||||
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||
return b;
|
||||
}));
|
||||
}
|
||||
|
||||
function categoryData(payload, tab) {
|
||||
if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) };
|
||||
if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) };
|
||||
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) {
|
||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||||
state.notificationsTab = normalizedTab;
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||||
const selected = node.dataset.tab === normalizedTab;
|
||||
node.classList.toggle('is-active', selected);
|
||||
node.dataset.selected = selected ? 'true' : 'false';
|
||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
async function load() {
|
||||
const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab));
|
||||
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);}
|
||||
}
|
||||
|
||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||||
setActiveNotificationTab(state.notificationsTab);
|
||||
|
||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||||
if (state.notificationsTab === nextTab) {
|
||||
setActiveNotificationTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNotificationTab(nextTab);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies';
|
||||
screen.cleanup = () => {
|
||||
observer?.disconnect();
|
||||
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
screen.append(tabs,list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
@@ -107,9 +107,9 @@ export function render({ navigate, chrome }) {
|
||||
screen.className = 'stack profile-screen';
|
||||
|
||||
chrome?.setTopbar(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Редактирование профиля',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -454,7 +454,7 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = values.map((value) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
|
||||
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
|
||||
)).join('');
|
||||
};
|
||||
|
||||
|
||||
+160
-143
@@ -1,12 +1,9 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
loadProfileSnapshot,
|
||||
} from '../services/user-profile-params.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { userDisplayName } from '../services/user-display.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -19,171 +16,194 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function fieldMap(snapshot) {
|
||||
const out = {};
|
||||
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
|
||||
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
|
||||
});
|
||||
return out;
|
||||
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||
const numericValue = Number(value || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||
data-profile-list="${escapeHtml(kind)}"
|
||||
aria-label="${escapeHtml(label)}: ${numericValue}"
|
||||
>
|
||||
<span class="user-profile-metric-circle">${numericValue}</span>
|
||||
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function openTextModal(title, text) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
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>`;
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#profile-text-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-text-modal') close();
|
||||
});
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function statusBadges(accountRole, shineStatus) {
|
||||
const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
|
||||
const shine = shineStatus === 'shining' ? 'Сияющий' : '';
|
||||
return `<div class="row wrap-row">
|
||||
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
|
||||
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
|
||||
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
function statsRows(stats = {}) {
|
||||
return [
|
||||
['friends', 'Друзья', stats.friendsCount],
|
||||
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
|
||||
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
|
||||
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
|
||||
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
|
||||
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
|
||||
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
|
||||
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
|
||||
];
|
||||
if (!rows.length) {
|
||||
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||
}
|
||||
return rows.map(([label, value]) => `
|
||||
<div class="user-profile-contact-row">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<b>${escapeHtml(value)}</b>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = String(state.session.login || profile.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profile-screen';
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||
topbar.innerHTML = `
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
</div>`;
|
||||
const menuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: menuButton,
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
items: [
|
||||
{ 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-settings.svg', action: () => navigate('settings-view') },
|
||||
const topbar = createTopBar({
|
||||
title: login || 'Профиль',
|
||||
className: 'topbar--profile user-profile-header',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню профиля',
|
||||
ariaLabel: 'Меню профиля',
|
||||
className: 'profile-head-menu-btn',
|
||||
menu: {
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 250,
|
||||
items: [
|
||||
{ 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-settings.svg', action: () => navigate('settings-view') },
|
||||
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
|
||||
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
|
||||
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'status-line';
|
||||
status.className = 'status-line user-profile-status';
|
||||
status.textContent = 'Загрузка профиля...';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'stack';
|
||||
body.className = 'user-profile-body';
|
||||
screen.append(status, body);
|
||||
|
||||
let current = null;
|
||||
let card = null;
|
||||
|
||||
function renderProfile() {
|
||||
if (!current) return;
|
||||
const { snapshot, user } = current;
|
||||
const fields = fieldMap(snapshot);
|
||||
const firstName = fields.first_name || '';
|
||||
const lastName = fields.last_name || '';
|
||||
const displayName = userDisplayName({ login, firstName, lastName });
|
||||
const avatar = snapshot?.avatar?.txId
|
||||
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null;
|
||||
const stats = {
|
||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||
friendsCount: Number(user?.friendsCount || 0),
|
||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
|
||||
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
|
||||
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
|
||||
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
|
||||
};
|
||||
if (!card) return;
|
||||
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();
|
||||
|
||||
body.innerHTML = '';
|
||||
const identity = document.createElement('div');
|
||||
identity.className = 'card row';
|
||||
identity.style.gap = '12px';
|
||||
identity.style.alignItems = 'center';
|
||||
identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
|
||||
const identityText = document.createElement('div');
|
||||
identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
|
||||
identity.append(identityText);
|
||||
body.append(identity);
|
||||
const title = topbar.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login || login || 'Профиль';
|
||||
|
||||
const badges = document.createElement('div');
|
||||
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
|
||||
body.append(...badges.children);
|
||||
body.innerHTML = `
|
||||
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
|
||||
|
||||
if (fields.about) {
|
||||
const about = document.createElement('div');
|
||||
about.className = 'card profile-about';
|
||||
about.style.whiteSpace = 'pre-wrap';
|
||||
about.textContent = fields.about;
|
||||
body.append(about);
|
||||
}
|
||||
<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>
|
||||
|
||||
const statsGrid = document.createElement('div');
|
||||
statsGrid.className = 'profile-stats-grid';
|
||||
statsRows(stats).forEach(([kind, label, value]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'card profile-stat-card';
|
||||
button.dataset.profileList = kind;
|
||||
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
|
||||
statsGrid.append(button);
|
||||
});
|
||||
body.append(statsGrid);
|
||||
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
|
||||
|
||||
const detailRow = document.createElement('div');
|
||||
detailRow.className = 'row wrap-row';
|
||||
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
|
||||
body.append(detailRow);
|
||||
<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>
|
||||
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-actions" aria-label="Действия со своим профилем">
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true">
|
||||
</button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true">
|
||||
</button>
|
||||
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-detail-links" aria-label="Дополнительная информация о профиле">
|
||||
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
|
||||
<span class="user-profile-detail-tab-label">Духовный путь</span>
|
||||
</button>
|
||||
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
|
||||
<span class="user-profile-detail-tab-label">Контакты</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="profile-view-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,
|
||||
}));
|
||||
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
body.addEventListener('click', (event) => {
|
||||
if (!current) return;
|
||||
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
|
||||
if (!el) return;
|
||||
const kind = el.dataset.profileList;
|
||||
if (kind) {
|
||||
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
|
||||
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 fields = fieldMap(current.snapshot);
|
||||
if (el.dataset.profileDetail === 'contacts') {
|
||||
openTextModal('Контакты', [
|
||||
fields.web ? `Links: ${fields.web}` : '',
|
||||
fields.phone ? `Телефон: ${fields.phone}` : '',
|
||||
fields.address ? `Адрес: ${fields.address}` : '',
|
||||
].filter(Boolean).join('\n') || 'Не заполнено');
|
||||
|
||||
const actionButton = event.target.closest('[data-self-profile-action]');
|
||||
if (actionButton) {
|
||||
const action = actionButton.dataset.selfProfileAction;
|
||||
if (action === 'edit') navigate('profile-edit-view');
|
||||
if (action === 'wallet') navigate('wallet-view');
|
||||
if (action === 'settings') navigate('settings-view');
|
||||
return;
|
||||
}
|
||||
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
|
||||
|
||||
const detailButton = event.target.closest('[data-profile-detail]');
|
||||
if (!detailButton) return;
|
||||
const detailKind = detailButton.dataset.profileDetail;
|
||||
const detailPanel = body.querySelector('#profile-view-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;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
@@ -191,17 +211,14 @@ export function render({ navigate, chrome }) {
|
||||
status.textContent = 'Локальный тестовый режим.';
|
||||
return;
|
||||
}
|
||||
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
|
||||
current = { snapshot, user };
|
||||
card = await loadUserProfileCard(login);
|
||||
renderProfile();
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
refresh().catch((error) => {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.className = 'status-line user-profile-status is-unavailable';
|
||||
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||
});
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createTopBar } from '../components/topbar.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, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profiles-screen';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Профили',
|
||||
back: { 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,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { defaultSolanaCluster } from '../deploy-config.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||
@@ -262,7 +262,7 @@ function readTicketFromUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -404,11 +404,11 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Очередь билета',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('start-view') },
|
||||
}));
|
||||
screen.append(
|
||||
content,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
|
||||
|
||||
@@ -265,16 +265,14 @@ function buildRecommendations(diag) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Диагностика PWA / Push',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
|
||||
const statusCard = document.createElement('div');
|
||||
statusCard.className = 'card stack';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
@@ -424,9 +424,9 @@ export function render({ navigate }) {
|
||||
renderInputStage();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Зарегистрироваться',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
resetRegistrationFlow();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
@@ -178,9 +178,9 @@ export function render({ navigate }) {
|
||||
actions.append(backButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Сгенерированные ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
back: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
|
||||
@@ -215,9 +215,9 @@ export function render({ navigate }) {
|
||||
actions.append(backButton, registerButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Вопросы о регистрации',
|
||||
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||
back: { label: '←', onClick: () => navigate('register-view') },
|
||||
}),
|
||||
heroCard,
|
||||
topicCard,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
cancelAddProfileLogin,
|
||||
consumeAuthReturnPage,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
resetRegistrationFlow,
|
||||
@@ -103,7 +105,12 @@ export function render({ navigate }) {
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => {
|
||||
cancelButton.addEventListener('click', async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
});
|
||||
@@ -143,7 +150,7 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||
}
|
||||
|
||||
await clearStoredMessages().catch(() => {});
|
||||
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||
|
||||
authorizeSession({
|
||||
login: state.registrationDraft.login,
|
||||
@@ -174,13 +181,7 @@ export function render({ navigate }) {
|
||||
setAuthInfo(isLoginFlow
|
||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||
const nextHash = String(state.authReturnHash || '').trim();
|
||||
state.authReturnHash = '';
|
||||
if (nextHash.startsWith('/')) {
|
||||
navigate(nextHash.slice(1));
|
||||
} else {
|
||||
navigate('profile-view');
|
||||
}
|
||||
navigate(consumeAuthReturnPage('profile-view'));
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||
setAuthError(message);
|
||||
@@ -192,11 +193,16 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, okButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Сохранение ключей',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
if (state.authReturnHash === '/profiles') {
|
||||
await cancelAddProfileLogin();
|
||||
navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
resetRegistrationFlow();
|
||||
navigate('start-view');
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
|
||||
},
|
||||
);
|
||||
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 resumedLogin = resumed.login || result.login;
|
||||
@@ -374,9 +374,9 @@ export function render({ navigate }) {
|
||||
card.append(showKeysButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Оплата регистрации',
|
||||
leftAction: { label: '←', onClick: () => navigate('register-view') },
|
||||
back: { label: '←', onClick: () => navigate('register-view') },
|
||||
}),
|
||||
card,
|
||||
);
|
||||
@@ -398,7 +398,7 @@ export function render({ navigate }) {
|
||||
function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
|
||||
const screen = document.querySelector('section.stack');
|
||||
if (!screen) return;
|
||||
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
|
||||
const headerBackButton = screen.querySelector('.topbar .topbar__back');
|
||||
const card = screen.querySelector('.card.stack');
|
||||
if (!card) return;
|
||||
card.classList.add('registration-finish-card');
|
||||
@@ -585,7 +585,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
||||
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
|
||||
const screen = document.querySelector('section.stack');
|
||||
if (!screen) return;
|
||||
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
|
||||
const headerBackButton = screen.querySelector('.topbar .topbar__back');
|
||||
const card = screen.querySelector('.card.stack');
|
||||
if (!card) return;
|
||||
card.classList.add('registration-finish-card');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
@@ -29,16 +29,14 @@ function sessionLabel(session) {
|
||||
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'AddBlock через homeserver',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
@@ -69,7 +67,7 @@ export function render({ navigate }) {
|
||||
sessions.forEach((session) => {
|
||||
const sessionId = String(session?.sessionId || '').trim();
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const isSelected = sessionId && sessionId === selectedId;
|
||||
item.innerHTML = `
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { saveEntrySettings, state } from '../state.js';
|
||||
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
|
||||
|
||||
@@ -10,7 +10,7 @@ const SERVER_FIELDS = [
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -185,7 +185,7 @@ export function render({ navigate }) {
|
||||
actions.append(cancelButton, saveButton);
|
||||
|
||||
const help = document.createElement('button');
|
||||
help.className = 'help-fab';
|
||||
help.className = 'ui-button help-fab';
|
||||
help.type = 'button';
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
@@ -194,11 +194,11 @@ export function render({ navigate }) {
|
||||
);
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Серверы блокчейнов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
introCard,
|
||||
body,
|
||||
actions,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -25,17 +25,15 @@ function formatVersionForUi(rawValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
let isDisposed = false;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
@@ -77,7 +75,7 @@ export function render({ navigate }) {
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
|
||||
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -88,9 +86,8 @@ export function render({ navigate }) {
|
||||
source: 'session',
|
||||
message: 'Запрошено завершение текущей сессии',
|
||||
});
|
||||
await closeCurrentSessionAndSignOut({
|
||||
infoMessage: 'Сеанс завершён. Выполните вход заново.',
|
||||
});
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||
} finally {
|
||||
signOutBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
@@ -6,7 +6,7 @@ import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -22,12 +22,10 @@ export function render({ navigate }) {
|
||||
device: '',
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
|
||||
export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
|
||||
|
||||
@@ -132,7 +132,7 @@ function makeResultCard(endpoint) {
|
||||
return { card, badge, statusLine, details };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -242,11 +242,11 @@ export function render({ navigate }) {
|
||||
});
|
||||
resetBtn.addEventListener('click', resetState);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Проверка Solana RPC',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
intro,
|
||||
summary,
|
||||
grid,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
SHINE_USERS_ECONOMY_CONFIG_SEED,
|
||||
SHINE_USERS_PROGRAM_ID,
|
||||
@@ -29,7 +29,7 @@ function shortAddr(value = '') {
|
||||
return `${v.slice(0, 6)}...${v.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -184,11 +184,11 @@ export function render({ navigate }) {
|
||||
status,
|
||||
);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Solana Init (users)',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
card,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
createSolanaWalletFromPrivateBase58,
|
||||
@@ -161,9 +161,9 @@ export function render({ navigate }) {
|
||||
})();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
createTopBar({
|
||||
title: 'Пополнение solana счета',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
back: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
status,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, setAuthError, setAuthInfo, state } from '../state.js';
|
||||
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -18,7 +18,7 @@ function describeState(settings) {
|
||||
return 'Вход через другое устройство разрешён без дополнительного пароля.';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -182,11 +182,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки входа через устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}));
|
||||
screen.append(
|
||||
card,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
@@ -18,13 +18,17 @@ const TITLES = {
|
||||
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
|
||||
};
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
export function render({navigate, route, chrome}) {
|
||||
const login = String(route?.params?.login || '').trim();
|
||||
const kind = String(route?.params?.kind || '').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 = 'Загрузка...';
|
||||
screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
|
||||
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } }));
|
||||
screen.append(
|
||||
status,
|
||||
body,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -32,7 +36,7 @@ export function render({ navigate, route }) {
|
||||
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
|
||||
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
|
||||
@@ -46,7 +50,7 @@ export function render({ navigate, route }) {
|
||||
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
|
||||
const rows = Array.isArray(payload?.users) ? payload.users : [];
|
||||
rows.forEach((row) => {
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
|
||||
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
|
||||
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
|
||||
const fullName = userDisplayName(row);
|
||||
const t = document.createElement('div'); t.className = 'profile-list-row-text';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
@@ -87,9 +87,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const header = renderHeader({
|
||||
const header = createTopBar({
|
||||
title: requestedLogin || 'Профиль',
|
||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
||||
back: { label: '←', onClick: () => navigateBack() },
|
||||
});
|
||||
header.classList.add('user-profile-header');
|
||||
chrome?.setTopbar(header);
|
||||
@@ -176,7 +176,7 @@ export function render({ navigate, route, chrome }) {
|
||||
.join(' ');
|
||||
const about = String(card.about || '').trim();
|
||||
|
||||
const title = header.querySelector('.page-title');
|
||||
const title = header.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login;
|
||||
|
||||
body.innerHTML = `
|
||||
@@ -214,10 +214,10 @@ export function render({ navigate, route, chrome }) {
|
||||
</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">
|
||||
<button type="button" class="ui-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">
|
||||
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
|
||||
<span class="user-profile-detail-tab-label">Контакты</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
@@ -7,11 +7,15 @@ export const pageMeta = { id: 'user-relation-manage-view', title: 'Добави
|
||||
|
||||
function effectiveSocial(f) { if (f.outCloseFriend) return 'close_friend'; if (f.outFriend) return 'friend'; if (f.outContact) return 'contact'; return 'none'; }
|
||||
|
||||
export function render({ route }) {
|
||||
export function render({route, chrome}) {
|
||||
const targetLogin = String(route?.params?.login || '').trim(); const selfLogin = String(state.session.login || '').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';
|
||||
screen.append(renderHeader({ title: 'Добавить', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
|
||||
chrome?.setTopbar(createTopBar({ title: 'Добавить', back: { label: '←', onClick: () => navigateBack() } }));
|
||||
screen.append(
|
||||
status,
|
||||
body,
|
||||
);
|
||||
let flags, selfCard, targetCard;
|
||||
|
||||
async function setKind(kind, enabled) { await authService.setUserRelation({ login: selfLogin, toLogin: targetLogin, kind, enabled, storagePwd: state.session.storagePwdInMemory }); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { defaultSolanaCluster } from '../deploy-config.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
@@ -446,7 +446,7 @@ function ticketPdaFor(programId, queueId, index) {
|
||||
);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -459,11 +459,11 @@ export function render({ navigate }) {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Кошелёк',
|
||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('profile-view') },
|
||||
}));
|
||||
screen.append(
|
||||
content,
|
||||
status,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ const PRETTY_PATHS = new Map([
|
||||
['key-storage-view', 'key-storage'],
|
||||
['profile-view', 'profile'],
|
||||
['profile-edit-view', 'profile/edit'],
|
||||
['profiles-view', 'profiles'],
|
||||
['messages-list', 'messages'],
|
||||
['contact-search-view', 'contacts'],
|
||||
['chat-view', 'chat'],
|
||||
@@ -248,6 +249,10 @@ export function parseRouteFromPath(pathname = '') {
|
||||
return { pageId: 'profile-view', params: {} };
|
||||
}
|
||||
|
||||
if (pageId === 'profiles') {
|
||||
return { pageId: 'profiles-view', params: {} };
|
||||
}
|
||||
|
||||
if (pageId === 'messages') {
|
||||
return { pageId: 'messages-list', params: {} };
|
||||
}
|
||||
@@ -437,6 +442,7 @@ export function resolveToolbarActive(pageId) {
|
||||
) return pageId;
|
||||
if (
|
||||
pageId === 'profile-edit-view' ||
|
||||
pageId === 'profiles-view' ||
|
||||
pageId === 'wallet-view' ||
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'access-servers-view' ||
|
||||
|
||||
@@ -362,12 +362,12 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
|
||||
slide.className = 'message-attachment-slide';
|
||||
const prev = document.createElement('button');
|
||||
prev.type = 'button';
|
||||
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
|
||||
prev.className = 'ui-button message-attachment-arrow message-attachment-arrow--prev';
|
||||
prev.textContent = '‹';
|
||||
prev.setAttribute('aria-label', 'Предыдущее вложение');
|
||||
const next = document.createElement('button');
|
||||
next.type = 'button';
|
||||
next.className = 'message-attachment-arrow message-attachment-arrow--next';
|
||||
next.className = 'ui-button message-attachment-arrow message-attachment-arrow--next';
|
||||
next.textContent = '›';
|
||||
next.setAttribute('aria-label', 'Следующее вложение');
|
||||
const counter = document.createElement('div');
|
||||
|
||||
@@ -249,6 +249,11 @@ function uint8Bytes(value) {
|
||||
}
|
||||
|
||||
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_OUTGOING_COPY = 2;
|
||||
const DM_TYPE_READ_INCOMING = 3;
|
||||
@@ -994,6 +999,8 @@ export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.eventListeners = new Map();
|
||||
this.wsEventUnsubscribers = new Map();
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks = new Map();
|
||||
this.passwordKeyBundleCache = new Map();
|
||||
@@ -1003,14 +1010,39 @@ export class AuthService {
|
||||
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);
|
||||
if (normalized === this.serverUrl) return;
|
||||
this.ws.close();
|
||||
try { this.ws?.close(); } catch {}
|
||||
this.serverUrl = normalized;
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
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 = '' } = {}) {
|
||||
@@ -2509,7 +2541,28 @@ export class AuthService {
|
||||
|
||||
|
||||
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 || '' }) {
|
||||
@@ -2891,14 +2944,40 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getNotifications(limit = 50) {
|
||||
const payload = {};
|
||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||
const response = await this.ws.request('GetNotifications', payload);
|
||||
async getNotifications(countsOnly = false) {
|
||||
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||
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) {
|
||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||
@@ -2951,6 +3030,15 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async listUserSettings(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Не передан login');
|
||||
|
||||
const response = await this.ws.request('ListUserSettings', { login: cleanLogin });
|
||||
if (response.status !== 200) throw opError('ListUserSettings', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async upsertUserSetting({
|
||||
login,
|
||||
settingType,
|
||||
|
||||
@@ -1,16 +1,67 @@
|
||||
const DB_NAME = 'shine-ui-messages-v1';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const DB_VERSION = 3;
|
||||
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() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
let store;
|
||||
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||
const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
|
||||
store.createIndex('by_chat', 'chatId', { unique: false });
|
||||
store.createIndex('by_ts', 'ts', { unique: false });
|
||||
store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'storageKey' });
|
||||
} else {
|
||||
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);
|
||||
@@ -36,31 +87,55 @@ async function withStore(mode, callback) {
|
||||
|
||||
export async function putStoredMessage(record) {
|
||||
if (!record || !record.messageKey) return;
|
||||
const ownerLogin = normalizeOwnerLogin(record.ownerLogin);
|
||||
const key = storageKey(ownerLogin, record.messageKey);
|
||||
if (!key) return;
|
||||
await withStore('readwrite', (store) => {
|
||||
store.put(record);
|
||||
store.put({ ...record, ownerLogin, storageKey: key });
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteStoredMessage(messageKey) {
|
||||
if (!messageKey) return;
|
||||
export async function deleteStoredMessage(messageKey, ownerLogin = '') {
|
||||
const key = storageKey(ownerLogin, messageKey);
|
||||
if (!key) return;
|
||||
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) => {
|
||||
const req = store.getAll();
|
||||
const req = store.index('by_owner').getAll(owner);
|
||||
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() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
|
||||
});
|
||||
export async function clearStoredMessages(ownerLogin = '') {
|
||||
const owner = normalizeOwnerLogin(ownerLogin);
|
||||
if (!owner) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
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 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 WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-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() {
|
||||
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 {
|
||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -169,6 +285,15 @@ function clearStoredSession() {
|
||||
}
|
||||
|
||||
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 {
|
||||
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -199,6 +324,19 @@ function persistEntrySettings(settings) {
|
||||
tools: normalizeToolsSettings(settings?.tools),
|
||||
};
|
||||
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 {
|
||||
// ignore storage errors
|
||||
}
|
||||
@@ -244,6 +382,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
pendingIncomingReadByBaseKey: {},
|
||||
outgoingTempSeq: 1,
|
||||
notificationsTab: 'replies',
|
||||
notificationUnreadTotal: 0,
|
||||
pageLabelCollapsed: false,
|
||||
session: {
|
||||
isAuthorized: storedLocalDemo,
|
||||
@@ -401,6 +540,7 @@ function persistMessageRecord(chatId, row) {
|
||||
const resolvedTs = resolveChatMessageTimeMs(row);
|
||||
void putStoredMessage({
|
||||
messageKey: row.messageKey,
|
||||
ownerLogin: String(state.session.login || '').trim().toLowerCase(),
|
||||
chatId: normalizedChatId,
|
||||
from: row.from || 'in',
|
||||
text: String(row.text || ''),
|
||||
@@ -421,12 +561,12 @@ function persistMessageRecord(chatId, row) {
|
||||
|
||||
function removeStoredMessageRecord(messageKey) {
|
||||
if (!messageKey) return;
|
||||
void deleteStoredMessage(messageKey).catch(() => {});
|
||||
void deleteStoredMessage(messageKey, state.session.login).catch(() => {});
|
||||
}
|
||||
|
||||
export async function hydrateMessagesFromStore() {
|
||||
try {
|
||||
const rows = await listStoredMessages();
|
||||
const rows = await listStoredMessages(state.session.login);
|
||||
const touchedChats = new Set();
|
||||
rows
|
||||
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
||||
@@ -927,6 +1067,7 @@ export function authorizeSession({
|
||||
login,
|
||||
sessionId,
|
||||
});
|
||||
upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings });
|
||||
authService.setActiveSessionContext({ login, sessionId });
|
||||
state.startHint = '';
|
||||
if (onSessionAuthorized) {
|
||||
@@ -1019,13 +1160,14 @@ async function tryCloseCurrentSessionOnServer() {
|
||||
}
|
||||
|
||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
||||
const signedOutLogin = String(state.session.login || '').trim();
|
||||
if (closeServerSession) {
|
||||
await tryCloseCurrentSessionOnServer();
|
||||
}
|
||||
|
||||
clearStoredSession();
|
||||
resetStateForSignedOut();
|
||||
await clearStoredMessages().catch(() => {});
|
||||
await clearStoredMessages(signedOutLogin).catch(() => {});
|
||||
authService.close();
|
||||
authService.clearActiveSessionContext();
|
||||
if (infoMessage) {
|
||||
@@ -1045,6 +1187,211 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
||||
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() {
|
||||
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
||||
state.registrationPayment.balanceSOL = next;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* App Shell: единая геометрия и глобальные UI-слои приложения.
|
||||
* Feature-specific стили находятся в styles/features/* и network-graph.css.
|
||||
*/
|
||||
.app-shell {
|
||||
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||
height: var(--app-viewport-height, 100vh);
|
||||
position: fixed;
|
||||
top: var(--app-viewport-offset-top, 0px);
|
||||
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
|
||||
transform: translateX(-50%);
|
||||
--call-minimized-bar-height: 0px;
|
||||
--topbar-height: 0px;
|
||||
--composer-height: 0px;
|
||||
--toolbar-height: 78px;
|
||||
--keyboard-offset: 0px;
|
||||
--z-shell-content: 1;
|
||||
--z-shell-fade: 10;
|
||||
--z-shell-chrome: 20;
|
||||
--z-shell-status: 30;
|
||||
background: transparent;
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
position: absolute;
|
||||
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
|
||||
z-index: var(--z-shell-content);
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.screen-content.no-app-chrome {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
bottom: 0;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.app-shell.has-minimized-call .screen-content {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.topbar-slot,
|
||||
.composer-slot,
|
||||
.toolbar-slot {
|
||||
z-index: var(--z-shell-chrome);
|
||||
}
|
||||
|
||||
.topbar-slot[hidden],
|
||||
.composer-slot[hidden],
|
||||
.toolbar-slot[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-slot {
|
||||
position: absolute;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topbar-slot > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composer-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: min(var(--app-viewport-width, 100vw), 430px);
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composer-slot > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toolbar-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.connection-retry-banner {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(96px + env(safe-area-inset-bottom));
|
||||
z-index: var(--z-shell-status);
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(133, 156, 201, 0.3);
|
||||
background: rgba(10, 19, 37, 0.86);
|
||||
color: #c6d6f7;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
padding: 7px 10px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.connection-retry-banner.is-connected {
|
||||
border-color: rgba(124, 235, 171, 0.4);
|
||||
color: #d8ffe9;
|
||||
}
|
||||
|
||||
.connection-retry-banner.is-connecting {
|
||||
border-color: transparent;
|
||||
color: #ffe8bb;
|
||||
}
|
||||
|
||||
.connection-retry-banner.is-disconnected {
|
||||
border-color: rgba(228, 127, 145, 0.44);
|
||||
color: #ffdce3;
|
||||
}
|
||||
|
||||
.connection-retry-banner.is-updating {
|
||||
border-color: rgba(144, 201, 255, 0.44);
|
||||
color: #d9eeff;
|
||||
}
|
||||
|
||||
/* Обычный scroll-container физически продолжается под прозрачным TopBar. */
|
||||
.app-shell--content-under-topbar:not(.app-shell--scroll-nested):not(.app-shell--scroll-locked) .screen-content:not(.no-app-chrome) {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
padding-top: calc(var(--topbar-height, 64px) + 14px);
|
||||
}
|
||||
|
||||
/* Вложенный scroll-container (например, переписка) сам резервирует место под chrome. */
|
||||
.app-shell--scroll-nested .topbar-slot {
|
||||
position: fixed;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: min(100vw, 430px);
|
||||
margin: 0 auto;
|
||||
transform: none;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.app-shell--scroll-nested .screen-content {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.app-shell--content-under-bottom:not(.keyboard-open) .screen-content {
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Полноэкранный feature сам управляет внутренней геометрией, shell — viewport/слоями. */
|
||||
.app-shell--scroll-locked .screen-content {
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-shell--scrollbar-hidden .screen-content {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.app-shell--scrollbar-hidden .screen-content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Global shell fades: один механизм, режимы различаются только профилем кривой. */
|
||||
.app-shell-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-shell-fade);
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-shell--top-fade.app-shell--has-topbar .app-shell-fade--top {
|
||||
display: block;
|
||||
top: var(--call-minimized-bar-height, 0px);
|
||||
height: calc(var(--topbar-height, 64px) + 48px);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(5, 7, 10, 1) 0%,
|
||||
rgba(5, 7, 10, 0.98) 16%,
|
||||
rgba(5, 7, 10, 0.88) 32%,
|
||||
rgba(5, 7, 10, 0.66) 52%,
|
||||
rgba(5, 7, 10, 0.38) 70%,
|
||||
rgba(5, 7, 10, 0.15) 84%,
|
||||
rgba(5, 7, 10, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.app-shell--bottom-fade.app-shell--bottom-fade-composer.app-shell--has-composer .app-shell-fade--bottom {
|
||||
display: block;
|
||||
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
|
||||
height: calc(var(--composer-height, 0px) + 52px);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(5, 7, 10, 0) 0%,
|
||||
rgba(5, 7, 10, 0.14) 18%,
|
||||
rgba(5, 7, 10, 0.38) 38%,
|
||||
rgba(5, 7, 10, 0.66) 58%,
|
||||
rgba(5, 7, 10, 0.88) 76%,
|
||||
rgba(5, 7, 10, 0.98) 90%,
|
||||
rgba(5, 7, 10, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Иммерсивный edge-профиль использует тот же fade-layer, но прежнюю кривую графа. */
|
||||
.app-shell--fade-edge.app-shell--top-fade.app-shell--has-topbar .app-shell-fade--top {
|
||||
height: var(--topbar-height, 64px);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(5, 7, 10, 1) 0%,
|
||||
rgba(5, 7, 10, 1) 32%,
|
||||
rgba(5, 7, 10, 0.94) 38%,
|
||||
rgba(5, 7, 10, 0.72) 46%,
|
||||
rgba(5, 7, 10, 0.46) 54%,
|
||||
rgba(5, 7, 10, 0.22) 61%,
|
||||
rgba(5, 7, 10, 0.08) 65%,
|
||||
rgba(5, 7, 10, 0) 68%,
|
||||
rgba(5, 7, 10, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.app-shell--fade-edge.app-shell--bottom-fade.app-shell--bottom-fade-toolbar .app-shell-fade--bottom {
|
||||
display: block;
|
||||
bottom: 0;
|
||||
height: var(--toolbar-height, 78px);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(5, 7, 10, 0) 0%,
|
||||
rgba(5, 7, 10, 0) 32%,
|
||||
rgba(5, 7, 10, 0.08) 35%,
|
||||
rgba(5, 7, 10, 0.22) 39%,
|
||||
rgba(5, 7, 10, 0.46) 46%,
|
||||
rgba(5, 7, 10, 0.72) 54%,
|
||||
rgba(5, 7, 10, 0.94) 62%,
|
||||
rgba(5, 7, 10, 1) 68%,
|
||||
rgba(5, 7, 10, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* При toolbar-anchored fade сам toolbar остаётся прозрачным над общим shell-layer. */
|
||||
.app-shell--bottom-fade-toolbar .toolbar-slot {
|
||||
isolation: isolate;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-shell--bottom-fade-toolbar .toolbar-slot > .toolbar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Keyboard lifecycle остаётся частью App Shell, а не страницы чата. */
|
||||
.app-shell.keyboard-open .toolbar-slot {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.app-shell--scroll-nested.keyboard-open .composer-slot {
|
||||
bottom: var(--keyboard-offset, 0px);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.app-shell--scroll-nested.keyboard-open .screen-content {
|
||||
bottom: var(--keyboard-offset, 0px);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.app-shell {
|
||||
top: 16px;
|
||||
height: calc(100svh - 32px);
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
+137
-336
@@ -1,26 +1,26 @@
|
||||
/*
|
||||
* Единый визуальный язык кнопок основного приложения:
|
||||
* белое содержимое, без рамок и самостоятельной подложки.
|
||||
* Shared semantic button roles.
|
||||
*
|
||||
* Исключения:
|
||||
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
||||
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
||||
* Stage 4: styling is opt-in by an existing UI role class. Component-owned
|
||||
* controls (TopBar, Dropdown, Toolbar, tabs, ScrollToBottom, etc.) are styled
|
||||
* by their own owner stylesheet and are intentionally absent from this file.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root a.primary-btn,
|
||||
:root a.secondary-btn,
|
||||
:root a.destructive-btn,
|
||||
:root a.ghost-btn,
|
||||
:root a.icon-btn,
|
||||
:root a.text-btn {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
.ui-button,
|
||||
.primary-btn,
|
||||
.secondary-btn,
|
||||
.destructive-btn,
|
||||
.ghost-btn,
|
||||
.icon-btn,
|
||||
.text-btn,
|
||||
.shine-btn {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
@@ -28,350 +28,151 @@
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root a.primary-btn:hover,
|
||||
:root a.secondary-btn:hover,
|
||||
:root a.destructive-btn:hover,
|
||||
:root a.ghost-btn:hover,
|
||||
:root a.icon-btn:hover,
|
||||
:root a.text-btn:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
.ui-button:hover,
|
||||
.primary-btn:hover,
|
||||
.secondary-btn:hover,
|
||||
.destructive-btn:hover,
|
||||
.ghost-btn:hover,
|
||||
.icon-btn:hover,
|
||||
.text-btn:hover,
|
||||
.shine-btn:hover {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
||||
* Эффект существует только пока кнопка физически нажата.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root a.primary-btn:active,
|
||||
:root a.secondary-btn:active,
|
||||
:root a.destructive-btn:active,
|
||||
:root a.ghost-btn:active,
|
||||
:root a.icon-btn:active,
|
||||
:root a.text-btn:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
border: 0 !important;
|
||||
.primary-btn:hover,
|
||||
.secondary-btn:hover,
|
||||
.destructive-btn:hover,
|
||||
.ghost-btn:hover,
|
||||
.icon-btn:hover,
|
||||
.text-btn:hover,
|
||||
.shine-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ui-button:active,
|
||||
.primary-btn:active,
|
||||
.secondary-btn:active,
|
||||
.destructive-btn:active,
|
||||
.ghost-btn:active,
|
||||
.icon-btn:active,
|
||||
.text-btn:active,
|
||||
.shine-btn:active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
||||
:root a.primary-btn[aria-disabled='true'],
|
||||
:root a.secondary-btn[aria-disabled='true'],
|
||||
:root a.destructive-btn[aria-disabled='true'],
|
||||
:root a.ghost-btn[aria-disabled='true'],
|
||||
:root a.icon-btn[aria-disabled='true'],
|
||||
:root a.text-btn[aria-disabled='true'] {
|
||||
color: rgba(255, 255, 255, 0.42) !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
.ui-button:disabled,
|
||||
.primary-btn:disabled,
|
||||
.secondary-btn:disabled,
|
||||
.destructive-btn:disabled,
|
||||
.ghost-btn:disabled,
|
||||
.icon-btn:disabled,
|
||||
.text-btn:disabled,
|
||||
.shine-btn:disabled,
|
||||
.ui-button[aria-disabled='true'],
|
||||
.primary-btn[aria-disabled='true'],
|
||||
.secondary-btn[aria-disabled='true'],
|
||||
.destructive-btn[aria-disabled='true'],
|
||||
.ghost-btn[aria-disabled='true'],
|
||||
.icon-btn[aria-disabled='true'],
|
||||
.text-btn[aria-disabled='true'],
|
||||
.shine-btn[aria-disabled='true'] {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
||||
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
||||
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
||||
*/
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
/* Decorative button pseudo-elements were not part of the Stage 3.1 surface. */
|
||||
.ui-button::before,
|
||||
.primary-btn::before,
|
||||
.secondary-btn::before,
|
||||
.destructive-btn::before,
|
||||
.ghost-btn::before,
|
||||
.icon-btn::before,
|
||||
.text-btn::before,
|
||||
.shine-btn::before,
|
||||
.ui-button::after,
|
||||
.primary-btn::after,
|
||||
.secondary-btn::after,
|
||||
.destructive-btn::after,
|
||||
.ghost-btn::after,
|
||||
.icon-btn::after,
|
||||
.text-btn::after,
|
||||
.shine-btn::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
||||
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
||||
*/
|
||||
:root .toolbar-btn {
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
:root .toolbar-btn:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
background: rgba(0, 0, 0, 0.14) !important;
|
||||
box-shadow:
|
||||
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root a.primary-btn:focus-visible,
|
||||
:root a.secondary-btn:focus-visible,
|
||||
:root a.destructive-btn:focus-visible,
|
||||
:root a.ghost-btn:focus-visible,
|
||||
:root a.icon-btn:focus-visible,
|
||||
:root a.text-btn:focus-visible {
|
||||
.ui-button:focus-visible,
|
||||
.primary-btn:focus-visible,
|
||||
.secondary-btn:focus-visible,
|
||||
.destructive-btn:focus-visible,
|
||||
.ghost-btn:focus-visible,
|
||||
.icon-btn:focus-visible,
|
||||
.text-btn:focus-visible,
|
||||
.shine-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Уведомления: «Ответы / События».
|
||||
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
||||
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
||||
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.18) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
||||
transform: translateY(1px) scale(0.965) !important;
|
||||
filter: brightness(0.88) !important;
|
||||
}
|
||||
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
||||
color: #ffffff !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
||||
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
||||
*/
|
||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
||||
background: rgba(0, 0, 0, 0.12) !important;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
||||
transform: translateY(1px) scale(0.97) !important;
|
||||
filter: brightness(0.9) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
||||
* Это правило намеренно расположено после глобального белого button-rule. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: var(--app-topbar-gold) !important;
|
||||
}
|
||||
|
||||
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
||||
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
||||
}
|
||||
|
||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
||||
color: #FFFFFF !important;
|
||||
outline: none !important;
|
||||
filter:
|
||||
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
||||
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
||||
}
|
||||
|
||||
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
||||
:root .dm-chat-input button.dm-emoji-btn,
|
||||
:root .dm-chat-input button.dm-send-btn,
|
||||
:root .dm-chat-input button.dm-edit-banner__close,
|
||||
:root .dm-chat-input button.dm-emoji-btn:hover,
|
||||
:root .dm-chat-input button.dm-send-btn:hover,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
||||
:root .dm-chat-input button.dm-emoji-btn:focus,
|
||||
:root .dm-chat-input button.dm-send-btn:focus,
|
||||
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
||||
color: #F7FBFF !important;
|
||||
text-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
||||
}
|
||||
|
||||
/* Второй общий тип кнопок. Цвет меняется одной переменной --shine-action-blue
|
||||
* в main.css. Экран настройки сервера намеренно не получает этот класс. */
|
||||
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn),
|
||||
:root .screen-content.filled-action-buttons a.primary-btn,
|
||||
:root .screen-content.filled-action-buttons a.secondary-btn,
|
||||
:root .screen-content.filled-action-buttons a.ghost-btn,
|
||||
:root .screen-content.filled-action-buttons a.text-btn {
|
||||
color: #ffffff !important;
|
||||
background: var(--shine-action-blue) !important;
|
||||
background-image: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,0)) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.24),
|
||||
0 8px 22px rgba(var(--shine-action-blue-rgb), .24) !important;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,.18) !important;
|
||||
/* Explicit filled-action variant used by pre-auth/settings/profile screens. */
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close) {
|
||||
color: #ffffff;
|
||||
background: var(--shine-action-blue);
|
||||
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0));
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):hover,
|
||||
:root .screen-content.filled-action-buttons a.primary-btn:hover,
|
||||
:root .screen-content.filled-action-buttons a.secondary-btn:hover,
|
||||
:root .screen-content.filled-action-buttons a.ghost-btn:hover,
|
||||
:root .screen-content.filled-action-buttons a.text-btn:hover {
|
||||
background: var(--shine-action-blue-hover) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.28),
|
||||
0 10px 26px rgba(var(--shine-action-blue-rgb), .32) !important;
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
0 8px 22px rgba(var(--shine-action-blue-rgb), 0.24);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):active,
|
||||
:root .screen-content.filled-action-buttons a.primary-btn:active,
|
||||
:root .screen-content.filled-action-buttons a.secondary-btn:active,
|
||||
:root .screen-content.filled-action-buttons a.ghost-btn:active,
|
||||
:root .screen-content.filled-action-buttons a.text-btn:active {
|
||||
background: var(--shine-action-blue-pressed) !important;
|
||||
box-shadow: inset 0 3px 8px rgba(0,0,0,.26) !important;
|
||||
}
|
||||
|
||||
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):disabled {
|
||||
color: rgba(255,255,255,.58) !important;
|
||||
background: rgba(var(--shine-action-blue-rgb), .42) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Специальная системная кнопка должна сохранять синюю круглую подложку,
|
||||
* несмотря на глобальный borderless-reset выше. */
|
||||
:root button.scroll-to-bottom-btn,
|
||||
:root button.scroll-to-bottom-btn:hover,
|
||||
:root button.scroll-to-bottom-btn:focus,
|
||||
:root button.scroll-to-bottom-btn:focus-visible {
|
||||
color: #ffffff !important;
|
||||
background: var(--shine-action-blue) !important;
|
||||
border: 0 !important;
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):hover {
|
||||
background: var(--shine-action-blue-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.25),
|
||||
0 10px 26px rgba(var(--shine-action-blue-rgb), .34) !important;
|
||||
filter: none !important;
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 10px 26px rgba(var(--shine-action-blue-rgb), 0.32);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
:root button.scroll-to-bottom-btn:hover {
|
||||
background: var(--shine-action-blue-hover) !important;
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):active {
|
||||
background: var(--shine-action-blue-pressed);
|
||||
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.26);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
:root button.scroll-to-bottom-btn:active {
|
||||
background: var(--shine-action-blue-pressed) !important;
|
||||
box-shadow: inset 0 3px 9px rgba(0,0,0,.28) !important;
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Пункты унифицированных выпадающих меню остаются без постоянной заливки,
|
||||
* но получают общую синюю реакцию при наведении/фокусе. */
|
||||
:root button.dm-head-menu-item,
|
||||
:root button.channel-menu-item,
|
||||
:root button.dm-message-action-btn {
|
||||
justify-content: flex-start !important;
|
||||
color: #f4f8ff !important;
|
||||
background: transparent !important;
|
||||
text-align: left !important;
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::before,
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root button.dm-head-menu-item:hover,
|
||||
:root button.dm-head-menu-item:focus-visible,
|
||||
:root button.channel-menu-item:hover,
|
||||
:root button.channel-menu-item:focus-visible,
|
||||
:root button.dm-message-action-btn:hover,
|
||||
:root button.dm-message-action-btn:focus-visible {
|
||||
background: rgba(var(--shine-action-blue-rgb), .12) !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:root button.channel-menu-item.destructive,
|
||||
:root button.dm-message-action-btn--danger {
|
||||
color: #ffb7c5 !important;
|
||||
}
|
||||
|
||||
/* 2026-08-28: исключения из borderless-reset для явных выборов и действий настроек. */
|
||||
:root body .language-choice-grid button.language-choice-option,
|
||||
:root body .language-choice-grid button.language-choice-option:hover,
|
||||
:root body .language-choice-grid button.language-choice-option:focus {
|
||||
border: 1px solid rgba(210, 222, 241, 0.16) !important;
|
||||
border-radius: 16px !important;
|
||||
background: rgba(255, 255, 255, 0.035) !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:root body .language-choice-grid button.language-choice-option.is-selected,
|
||||
:root body .language-choice-grid button.language-choice-option.is-selected:hover,
|
||||
:root body .language-choice-grid button.language-choice-option.is-selected:focus {
|
||||
border-color: rgba(92, 190, 255, 0.62) !important;
|
||||
background: rgba(39, 141, 255, 0.12) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important;
|
||||
}
|
||||
|
||||
:root body button.pairing-approve-btn,
|
||||
:root body button.pairing-approve-btn:hover,
|
||||
:root body button.pairing-approve-btn:focus {
|
||||
background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important;
|
||||
border: 1px solid rgba(126, 235, 170, .55) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:root body button.pairing-reject-btn,
|
||||
:root body button.pairing-reject-btn:hover,
|
||||
:root body button.pairing-reject-btn:focus {
|
||||
color: #fff1f3 !important;
|
||||
background: rgba(134, 31, 49, .28) !important;
|
||||
border: 1px solid rgba(255, 105, 128, .42) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) {
|
||||
border: 1px solid rgba(183, 203, 235, 0.28) !important;
|
||||
border-radius: 14px !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%),
|
||||
rgba(8, 19, 42, .58) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.10),
|
||||
0 5px 16px rgba(0,0,0,.18) !important;
|
||||
padding-inline: 14px;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover {
|
||||
border-color: rgba(213, 225, 247, 0.42) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%),
|
||||
rgba(10, 24, 52, .68) !important;
|
||||
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):disabled {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
background: rgba(var(--shine-action-blue-rgb), 0.42);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
/* Shared attachment UI styles. */
|
||||
|
||||
.attachment-viewer-modal {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
|
||||
.attachment-viewer-card {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
max-height: 92vh;
|
||||
max-width: min(94vw, 68rem);
|
||||
padding: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.attachment-viewer-head,
|
||||
.attachment-viewer-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
.attachment-viewer-title {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.attachment-viewer-body {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
|
||||
.attachment-viewer-media {
|
||||
background: #020617;
|
||||
border-radius: 0.8rem;
|
||||
max-height: 76vh;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-manager-card {
|
||||
max-width: min(92vw, 34rem);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-manager-card--wide {
|
||||
max-width: min(96vw, 58rem);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-meta {
|
||||
color: #cbd5e1;
|
||||
display: grid;
|
||||
font-size: 0.86rem;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-wallet-select,
|
||||
.ar-attachment-wallet-select option {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-table-wrap {
|
||||
max-height: 55vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
min-width: 48rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-table th,
|
||||
.ar-attachment-history-table td {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
padding: 0.55rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-table th {
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tiles {
|
||||
align-content: start;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
grid-auto-rows: min-content;
|
||||
max-height: 58vh;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
|
||||
scrollbar-width: thin;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tiles::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tiles::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tiles::-webkit-scrollbar-thumb {
|
||||
background: rgba(212, 175, 55, 0.7);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tiles::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(240, 198, 76, 0.9);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(22, 36, 53, 0.96));
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.52rem;
|
||||
box-sizing: border-box;
|
||||
color: #f8fafc;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.28rem 0.42rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile.is-selected {
|
||||
border-color: rgba(34, 197, 94, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile--page {
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile--with-preview {
|
||||
gap: 0.32rem;
|
||||
padding: 0.42rem;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-preview {
|
||||
align-items: flex-end;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.42rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 7rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-preview-image {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-preview-badge {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
bottom: 0.42rem;
|
||||
color: #f8fafc;
|
||||
font-size: 0.62rem;
|
||||
left: 0.42rem;
|
||||
padding: 0.2rem 0.46rem;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile-head {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-name {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.08;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-tile-head strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-meta-row {
|
||||
align-items: center;
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.6rem;
|
||||
gap: 0.22rem;
|
||||
line-height: 1.05;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-history-txid {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.34rem;
|
||||
color: rgba(226, 232, 240, 0.86);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.05;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.14rem 0.24rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-status {
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0.2rem 0.32rem;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-status--available {
|
||||
background: rgba(220, 252, 231, 0.96);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-status--pending {
|
||||
background: rgba(254, 249, 195, 0.96);
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-status--unavailable {
|
||||
background: rgba(254, 226, 226, 0.96);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
|
||||
.ar-attachment-placement-flag {
|
||||
background: rgba(219, 234, 254, 0.96);
|
||||
border-radius: 999px;
|
||||
color: #1e3a8a;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0.2rem 0.32rem;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/* Shared avatar component styles. */
|
||||
|
||||
.avatar-preview-circle {
|
||||
width: 124px;
|
||||
height: 124px;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(155, 182, 233, 0.46);
|
||||
background: rgba(13, 26, 50, 0.86);
|
||||
box-shadow: inset 0 0 0 1px rgba(240, 248, 255, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.avatar-preview-circle img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.avatar-wizard-preview {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
|
||||
.avatar-wizard-meta {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: #d9e7ff;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.avatar-wizard-error {
|
||||
min-height: 18px;
|
||||
font-size: 13px;
|
||||
color: #f6a8b3;
|
||||
}
|
||||
|
||||
|
||||
.avatar-wizard-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.avatar-wizard-choice-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(130deg, #3c4f73, #243352);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #e5ebff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-xs,
|
||||
.avatar.xs {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-sm,
|
||||
.avatar.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-md,
|
||||
.avatar.medium {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-lg,
|
||||
.avatar.big {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-xl,
|
||||
.avatar.large,
|
||||
.avatar.xlarge {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
min-width: 96px;
|
||||
min-height: 96px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
|
||||
.avatar-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
|
||||
.avatar-image > .avatar-fallback,
|
||||
.avatar-image > img {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.avatar-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.avatar-image.has-image img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.avatar-image.has-image .avatar-fallback {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Единый шаблон аватаров (2026-08-22) =====
|
||||
* Базовая аватарка повторяет принцип орбов экрана «Связи»:
|
||||
* - fallback: нейтральный серый круг + белые инициалы;
|
||||
* - фото занимает тот же внутренний круг;
|
||||
* - стеклянный внешний круг остаётся поверх всегда, независимо от наличия фото;
|
||||
* - усиленное свечение включается модификатором .avatar-glow, не меняя геометрию аватара.
|
||||
*/
|
||||
.avatar.avatar-image.avatar-framed {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-image.avatar-framed > .avatar-fallback,
|
||||
.avatar.avatar-image.avatar-framed > .avatar-photo {
|
||||
grid-area: 1 / 1;
|
||||
place-self: center;
|
||||
width: 92.5%;
|
||||
height: 92.5%;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-image.avatar-framed > .avatar-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #454b55;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
inset 0 -8px 16px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-image.avatar-framed > .avatar-photo {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-image.avatar-framed.has-image > .avatar-photo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.avatar.avatar-image.avatar-framed.has-image > .avatar-fallback {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* Второй круг — тот же стеклянный overlay, который используется в «Связях». */
|
||||
.avatar.avatar-image.avatar-framed::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 119%;
|
||||
height: 119%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url("../assets/glass_overlay_faithful.png") center / contain no-repeat;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
|
||||
/* Опциональный усиленный ореол для состояний «сияющий» и будущих экранов. */
|
||||
.avatar.avatar-image.avatar-framed.avatar-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -16%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(139, 232, 255, 0.36) 0%, rgba(116, 217, 255, 0.14) 48%, rgba(116, 217, 255, 0) 74%);
|
||||
filter: blur(3px);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/* Shared call UI styles. */
|
||||
|
||||
.call-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.call-ui-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.call-ui-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.call-minimized-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: calc(8px + env(safe-area-inset-top)) 14px 8px;
|
||||
border-bottom: 1px solid rgba(159, 255, 196, 0.28);
|
||||
background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98));
|
||||
box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
.call-minimized-bar-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: #ffffff;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.call-minimized-bar-text:hover {
|
||||
color: #ffffff;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.call-minimized-bar-text:active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.call-minimized-bar-text:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.call-minimized-bar-text::before,
|
||||
.call-minimized-bar-text::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.call-minimized-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: #ffffff;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
cursor: pointer;
|
||||
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn:hover {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn:active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn:disabled {
|
||||
opacity: 1;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn::before,
|
||||
.call-icon-btn::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
/* secondary/danger visual shells were never observable in Stage 3.1; only geometry variants remain. */
|
||||
|
||||
|
||||
.call-icon-btn--bar {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
.call-icon-btn--bar svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(4, 8, 16, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(79, 124, 208, 0.24), transparent 34%),
|
||||
linear-gradient(180deg, rgba(11, 20, 38, 0.98), rgba(3, 7, 15, 1));
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-panel--remote-video {
|
||||
background: #010203;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-head {
|
||||
position: absolute;
|
||||
top: calc(12px + env(safe-area-inset-top));
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-title {
|
||||
margin: 0;
|
||||
min-height: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgba(236, 242, 255, 0.94);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.44);
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-minimize-btn {
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-status {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
z-index: 4;
|
||||
text-align: center;
|
||||
color: rgba(207, 219, 246, 0.84);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.call-video-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 24%, rgba(54, 88, 151, 0.48), transparent 30%),
|
||||
linear-gradient(180deg, rgba(9, 17, 32, 0.98), rgba(2, 5, 12, 1));
|
||||
}
|
||||
|
||||
|
||||
.call-video-stage--remote-video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
|
||||
.call-remote-video,
|
||||
.call-local-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: #02050a;
|
||||
}
|
||||
|
||||
|
||||
.call-remote-video[hidden],
|
||||
.call-local-preview[hidden],
|
||||
.call-hero[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
.call-hero {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: clamp(8vh, 16vh, 19vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
.call-peer-avatar-slot {
|
||||
width: min(42vw, 168px);
|
||||
height: min(42vw, 168px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
|
||||
.call-peer-avatar-slot .call-peer-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(221, 233, 255, 0.2);
|
||||
background: linear-gradient(180deg, rgba(18, 31, 58, 0.96), rgba(9, 15, 29, 0.98));
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
|
||||
.call-peer-avatar-slot .call-peer-avatar .avatar-fallback {
|
||||
font-size: clamp(34px, 9vw, 58px);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
||||
.call-peer-login {
|
||||
max-width: 86vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #f4f8ff;
|
||||
font-size: clamp(20px, 5.8vw, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
|
||||
.call-kind {
|
||||
color: #ffffff;
|
||||
font-size: clamp(27px, 8vw, 40px);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
.call-hero-status {
|
||||
max-width: 88vw;
|
||||
color: rgba(211, 223, 246, 0.9);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
.call-local-preview {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: calc(18px + env(safe-area-inset-bottom));
|
||||
z-index: 4;
|
||||
width: min(30vw, 130px);
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(176, 205, 255, 0.38);
|
||||
background: rgba(7, 12, 22, 0.94);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.call-local-preview:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-controls {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(env(safe-area-inset-bottom) + clamp(54px, 16vh, 112px));
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(92vw, 520px);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-controls[hidden],
|
||||
.call-overlay-controls--incoming[hidden],
|
||||
.call-overlay-controls--active[hidden],
|
||||
.call-icon-btn[hidden],
|
||||
.call-accept-btn[hidden],
|
||||
.call-decline-btn[hidden],
|
||||
.call-minimized-bar-actions[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-controls--incoming {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.call-overlay-controls--active {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.call-decline-btn {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn {
|
||||
min-width: 132px;
|
||||
min-height: 38px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: #ffffff;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn:hover {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn:active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn:disabled {
|
||||
opacity: 1;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.call-accept-btn::before,
|
||||
.call-accept-btn::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.call-overlay-controls {
|
||||
gap: 16px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + clamp(48px, 14vh, 96px));
|
||||
}
|
||||
.call-icon-btn {
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.call-icon-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.call-accept-btn,
|
||||
.call-decline-btn {
|
||||
min-width: 124px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
:root {
|
||||
--z-overlay: 40;
|
||||
--z-dropdown: 50;
|
||||
--z-modal: 60;
|
||||
}
|
||||
|
||||
.dropdown-portal {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-overlay);
|
||||
pointer-events: auto;
|
||||
background: rgba(2, 6, 14, 0.34);
|
||||
backdrop-filter: blur(1.5px);
|
||||
-webkit-backdrop-filter: blur(1.5px);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
min-width: 196px;
|
||||
max-width: calc(100vw - 20px);
|
||||
border: 1px solid rgba(92, 190, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(155deg, rgba(19, 27, 42, 0.97), rgba(7, 12, 22, 0.98));
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), 0 0 24px rgba(39, 141, 255, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(20px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(125%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu--transparent {
|
||||
border-color: rgba(92, 190, 255, 0.06);
|
||||
background: rgba(7, 12, 22, 0.44);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.dropdown-menu__item {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: #ffffff;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
.dropdown-menu__item.is-selected {
|
||||
background: rgba(39, 141, 255, 0.12);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dropdown-menu__item:hover {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dropdown-menu__item:active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dropdown-menu__item:focus-visible {
|
||||
color: #ffffff;
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
background: rgba(39, 141, 255, 0.12);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dropdown-menu__item.is-danger { color: #ffb7c5; }
|
||||
.dropdown-menu__item.is-danger:hover,
|
||||
.dropdown-menu__item.is-danger:active { color: #ffffff; }
|
||||
.dropdown-menu__item:disabled,
|
||||
.dropdown-menu__item.is-danger:disabled {
|
||||
opacity: 0.55;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dropdown-menu__item::before,
|
||||
.dropdown-menu__item::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dropdown-menu__icon,
|
||||
.dropdown-menu__icon svg,
|
||||
.dropdown-menu__item > img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
flex: 0 0 18px;
|
||||
object-fit: contain;
|
||||
color: #65b7ff;
|
||||
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
|
||||
}
|
||||
|
||||
.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: rgba(255, 255, 255, 0.08); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .dropdown-menu { animation: none; } }
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Emoji picker and Twemoji UI styles. */
|
||||
|
||||
.emoji-picker-slot {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-slot[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: min(40vh, 292px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(12, 19, 31, 0.98);
|
||||
box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker::-webkit-scrollbar,
|
||||
.emoji-picker-tabs::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-section-title {
|
||||
color: rgba(205, 218, 246, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab {
|
||||
flex: 0 0 38px;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab,
|
||||
.emoji-picker-item {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab {
|
||||
min-height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-item {
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab:hover,
|
||||
.emoji-picker-item:hover {
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab:focus,
|
||||
.emoji-picker-item:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab:active,
|
||||
.emoji-picker-item:active {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab:focus-visible,
|
||||
.emoji-picker-item:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.emoji-picker-tab::before,
|
||||
.emoji-picker-tab::after,
|
||||
.emoji-picker-item::before,
|
||||
.emoji-picker-item::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.twemoji,
|
||||
.emoji-picker-preview {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.12em;
|
||||
}
|
||||
|
||||
|
||||
.twemoji-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.twemoji.is-error .twemoji-image,
|
||||
.emoji-picker-preview.is-error .twemoji-image {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Shared modal/toast/skeleton primitives. */
|
||||
|
||||
.modal-title {
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
color: #f1d69a;
|
||||
}
|
||||
|
||||
|
||||
.modal-shell[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.modal-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 24;
|
||||
}
|
||||
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(5, 9, 16, 0.74);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
.modal-dialog {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
.modal-card {
|
||||
width: min(100%, 390px);
|
||||
background: #172238;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
|
||||
.modal-card {
|
||||
background: linear-gradient(165deg, rgba(16, 31, 58, 0.97), rgba(10, 18, 36, 0.97));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 20px 38px rgba(2, 6, 12, 0.55);
|
||||
}
|
||||
|
||||
|
||||
/* ===== Channels UX Stabilization ===== */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(18px + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.toast {
|
||||
min-width: min(88vw, 320px);
|
||||
max-width: min(92vw, 420px);
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid transparent;
|
||||
color: #f2dca8;
|
||||
background: rgba(10, 14, 23, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
|
||||
.toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
.toast.is-hiding {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
|
||||
.toast.toast--error {
|
||||
color: #ffd8e0;
|
||||
border-color: rgba(234, 122, 150, 0.45);
|
||||
}
|
||||
|
||||
|
||||
.skeleton-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.skeleton-line {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(100deg, rgba(93, 117, 154, 0.10), rgba(167, 191, 226, 0.24), rgba(93, 117, 154, 0.10));
|
||||
background-size: 220% 100%;
|
||||
animation: channels-shimmer 1.2s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
.skeleton-line.w-40 { width: 40%; }
|
||||
|
||||
.skeleton-line.w-70 { width: 70%; }
|
||||
|
||||
.skeleton-line.w-90 { width: 90%; }
|
||||
|
||||
|
||||
#about-channel-modal.modal,
|
||||
#edit-channel-modal.modal,
|
||||
#reply-modal.modal,
|
||||
#channel-message-modal.modal,
|
||||
#channel-edit-description-modal.modal,
|
||||
#channels-subscribe-modal.modal,
|
||||
#thread-reply-modal.modal {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
#about-channel-modal .modal-card,
|
||||
#edit-channel-modal .modal-card,
|
||||
#reply-modal .modal-card,
|
||||
#channel-message-modal .modal-card,
|
||||
#channel-edit-description-modal .modal-card,
|
||||
#channels-subscribe-modal .modal-card,
|
||||
#thread-reply-modal .modal-card {
|
||||
background: rgba(15, 18, 30, 0.92);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
#about-channel-modal .modal-title,
|
||||
#edit-channel-modal .modal-title,
|
||||
#reply-modal .modal-title,
|
||||
#channel-message-modal .modal-title,
|
||||
#channel-edit-description-modal .modal-title,
|
||||
#channels-subscribe-modal .modal-title,
|
||||
#thread-reply-modal .modal-title {
|
||||
color: rgba(255, 200, 50, 0.95);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Shared overflow-dots trigger. */
|
||||
|
||||
/* ===== UI cleanup: main tabs, shared overflow menu and DM list (2026-08-22) ===== */
|
||||
/* Единое вертикальное троеточие для всех overflow/menu-кнопок. */
|
||||
.app-overflow-dots {
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3.5px;
|
||||
color: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.app-overflow-dots i {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex: 0 0 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
0 0 5px rgba(92, 190, 255, 0.72),
|
||||
0 0 10px rgba(72, 145, 255, 0.34);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/* Shared UI primitives retained from the former components.css. */
|
||||
|
||||
/* Глобально отключаем синюю tap-подсветку мобильных браузеров/WebView на ВСЕХ элементах
|
||||
(Android/Chromium): синего квадрата при нажатии нигде быть не должно. */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
.icon-btn,
|
||||
.text-btn,
|
||||
.primary-btn,
|
||||
.secondary-btn,
|
||||
.destructive-btn,
|
||||
.ghost-btn {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
min-height: 38px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.icon-btn,
|
||||
.text-btn,
|
||||
.primary-btn,
|
||||
.secondary-btn,
|
||||
.destructive-btn,
|
||||
.ghost-btn {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.icon-btn:disabled,
|
||||
.text-btn:disabled,
|
||||
.primary-btn:disabled,
|
||||
.secondary-btn:disabled,
|
||||
.destructive-btn:disabled,
|
||||
.ghost-btn:disabled {
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(132, 244, 161, 0.35);
|
||||
color: #d7ffe3;
|
||||
background: rgba(132, 244, 161, 0.09);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.badge.alt {
|
||||
border-color: rgba(83, 216, 251, 0.35);
|
||||
color: #dff8ff;
|
||||
background: rgba(83, 216, 251, 0.11);
|
||||
}
|
||||
|
||||
|
||||
.badge.is-no {
|
||||
border-color: rgba(170, 180, 205, 0.3);
|
||||
color: #c5cedd;
|
||||
background: rgba(152, 164, 190, 0.14);
|
||||
}
|
||||
|
||||
|
||||
.badge.is-yes-official {
|
||||
border-color: rgba(132, 244, 161, 0.5);
|
||||
color: #ddffe7;
|
||||
background: rgba(132, 244, 161, 0.2);
|
||||
}
|
||||
|
||||
|
||||
.badge.is-yes-shine {
|
||||
border-color: rgba(183, 122, 255, 0.6);
|
||||
color: #f4e7ff;
|
||||
background: rgba(176, 102, 255, 0.22);
|
||||
}
|
||||
|
||||
|
||||
.field-label {
|
||||
color: #b2c2e6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(83, 216, 251, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
||||
}
|
||||
|
||||
|
||||
.wrap-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.status-line {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
.status-line.is-available {
|
||||
color: #8ef0a8;
|
||||
}
|
||||
|
||||
|
||||
.status-line.is-unavailable {
|
||||
color: #ff8d97;
|
||||
}
|
||||
|
||||
|
||||
.help-fab,
|
||||
.square-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.help-fab {
|
||||
position: fixed;
|
||||
right: max(20px, calc((100vw - min(100vw, 430px)) / 2 + 20px));
|
||||
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
|
||||
.inline-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.link-card {
|
||||
display: block;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(83, 216, 251, 0.08);
|
||||
border: 1px solid rgba(83, 216, 251, 0.22);
|
||||
color: #d9f8ff;
|
||||
}
|
||||
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.key-card {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.meta-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.unread {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
color: #08212a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
|
||||
.page-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
color: #c5d2f4;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
|
||||
.page-label.is-collapsed {
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
|
||||
.page-label-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.page-label-hint {
|
||||
margin-bottom: 3px;
|
||||
color: #8ea2cd;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
.page-label-caption {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
.page-label-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 90ms ease,
|
||||
box-shadow 90ms ease,
|
||||
background-color 90ms ease,
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
|
||||
.page-label-toggle::before,
|
||||
.page-label-toggle::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.page-label-toggle:hover {
|
||||
background: transparent;
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.page-label-toggle:active {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.page-label-toggle:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.chat-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 10px;
|
||||
min-height: calc(100dvh - 210px);
|
||||
}
|
||||
|
||||
|
||||
.messages-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
|
||||
.chat-input {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
color: #f3f7ff;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
caret-color: #f1d18a;
|
||||
-webkit-text-fill-color: #f3f7ff;
|
||||
}
|
||||
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(83, 216, 251, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
|
||||
}
|
||||
|
||||
|
||||
.input::placeholder {
|
||||
color: rgba(190, 206, 238, 0.82);
|
||||
}
|
||||
|
||||
|
||||
input.input:-webkit-autofill,
|
||||
input.input:-webkit-autofill:hover,
|
||||
input.input:-webkit-autofill:focus,
|
||||
textarea.input:-webkit-autofill,
|
||||
textarea.input:-webkit-autofill:hover,
|
||||
textarea.input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f3f7ff !important;
|
||||
caret-color: #f1d18a;
|
||||
border-color: var(--line);
|
||||
box-shadow: 0 0 0 1000px rgba(11, 24, 46, 0.96) inset !important;
|
||||
transition: background-color 9999s ease-out 0s;
|
||||
}
|
||||
|
||||
|
||||
textarea.input {
|
||||
padding: 10px 12px;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
|
||||
.inline-error {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
|
||||
.form-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.mono-cell {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
.small-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.key-value {
|
||||
font-family: "IBM Plex Mono", "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
color: #dce7ff;
|
||||
}
|
||||
|
||||
|
||||
.key-value--compact {
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
|
||||
.key-input {
|
||||
font-family: "IBM Plex Mono", "Fira Code", monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
|
||||
.key-toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
.key-toggle-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.boot-error-menu-shell {
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
|
||||
.boot-error-menu-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.boot-error-menu-message {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 126px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #dbe7ff;
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
|
||||
.is-springing {
|
||||
animation: spring-tap 0.28s ease;
|
||||
}
|
||||
|
||||
|
||||
#about-channel-modal .input,
|
||||
#edit-channel-modal .input,
|
||||
#reply-modal .input,
|
||||
#channel-message-modal .input,
|
||||
#channel-edit-description-modal .input,
|
||||
#channels-subscribe-modal .input,
|
||||
#thread-reply-modal .input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
#channel-edit-description-counter,
|
||||
#channel-edit-description-modal #channel-description-counter {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
#about-channel-modal .secondary-btn,
|
||||
#edit-channel-modal .secondary-btn,
|
||||
#reply-modal .secondary-btn,
|
||||
#channel-message-modal .secondary-btn,
|
||||
#channel-edit-description-modal .secondary-btn,
|
||||
#channels-subscribe-modal .secondary-btn,
|
||||
#thread-reply-modal .secondary-btn {
|
||||
border-radius: 12px;
|
||||
padding: 13px 24px;
|
||||
}
|
||||
|
||||
|
||||
#about-channel-modal .primary-btn,
|
||||
#edit-channel-modal .primary-btn,
|
||||
#reply-modal .primary-btn,
|
||||
#channel-message-modal .primary-btn,
|
||||
#channel-edit-description-modal .primary-btn,
|
||||
#channels-subscribe-modal .primary-btn,
|
||||
#thread-reply-modal .primary-btn {
|
||||
border-radius: 12px;
|
||||
padding: 13px 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
|
||||
даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
|
||||
html,
|
||||
body { overflow-x: hidden; }
|
||||
|
||||
|
||||
/* ===== Final microinteractions: breathe cards + static energy buttons ===== */
|
||||
@keyframes breatheCard {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
|
||||
@keyframes glareSweep {
|
||||
0% { left: -150%; }
|
||||
100% { left: 150%; }
|
||||
}
|
||||
|
||||
|
||||
@keyframes blurRevealMedium {
|
||||
0% { filter: blur(0px); opacity: 1; color: inherit; }
|
||||
40% { filter: blur(5px); opacity: 0; color: #D4AF37; }
|
||||
100% { filter: blur(0px); opacity: 1; color: #D4AF37; }
|
||||
}
|
||||
|
||||
|
||||
/* 3) Medium blur-reveal for stats/counters */
|
||||
.stat-blur-reveal {
|
||||
animation: blurRevealMedium 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
/* Use the shared application canvas for every primary navigation screen. */
|
||||
.dm-screen,
|
||||
.profile-screen,
|
||||
.notifications-screen {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.scroll-to-bottom-btn__icon {
|
||||
line-height: 1;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn {
|
||||
position: absolute;
|
||||
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px) + 18px + env(safe-area-inset-bottom));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 38;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
right: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Shared scroll-to-bottom control. */
|
||||
|
||||
/* ===== Общая кнопка прокрутки ленты вниз ===== */
|
||||
|
||||
|
||||
.scroll-to-bottom-btn.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
.scroll-to-bottom-btn:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn:active {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(1px) scale(0.97);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn:focus,
|
||||
.scroll-to-bottom-btn:focus-visible {
|
||||
background: var(--shine-action-blue);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 10px 26px rgba(var(--shine-action-blue-rgb), 0.34);
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn::before,
|
||||
.scroll-to-bottom-btn::after {
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.app-shell--scroll-nested.keyboard-open .scroll-to-bottom-btn {
|
||||
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px) + 18px);
|
||||
}
|
||||
|
||||
|
||||
/* Общая круглая кнопка прокрутки. Её цвет задаётся --shine-action-blue. */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user