Compare commits

..
2 Commits
Author SHA256 Message Date
tram f0b8fac598 Merge origin/main into ui-artem 2026-09-01 12:39:02 +03:00
tram 3ae9d9175f Обновить UI-ветку 2026-09-01 12:32:50 +03:00
158 changed files with 13894 additions and 19612 deletions
Binary file not shown.
@@ -102,23 +102,16 @@ public final class MsgSubType {
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 14;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 15;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
/** Удалить из контактов. */
public static final short CONNECTION_UNCONTACT = 21;
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
/** Подписаться (follow). */
public static final short CONNECTION_FOLLOW = 30;
/** Отписаться (unfollow). */
public static final short CONNECTION_UNFOLLOW = 31;
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
/** Добавить связь "жена/муж". */
public static final short CONNECTION_SPOUSE = 40;
/** Удалить связь "жена/муж". */
@@ -139,8 +132,9 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55;
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
@@ -148,17 +142,11 @@ public final class MsgSubType {
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
/** Снимаю подтверждение официального аккаунта этого человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
@@ -20,11 +20,9 @@ import java.util.Objects;
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
* SHINE_SEEN=74, SHINE_UNSEEN=75
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode
@@ -187,8 +185,6 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
@@ -206,9 +202,7 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
}
@Override
@@ -33,10 +33,6 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_14 = 14;
public static final int SCHEMA_VERSION_15 = 15;
public static final int SCHEMA_VERSION_16 = 16;
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";
@@ -53,10 +49,6 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
private DatabaseInitializer() {}
@@ -78,17 +70,12 @@ public final class DatabaseInitializer {
public static final short CONNECTION_CLOSE_FRIEND = 10;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
public static final short CONNECTION_FRIEND = 14;
public static final short CONNECTION_UNFRIEND = 15;
public static final short CONNECTION_CONTACT = 20;
public static final short CONNECTION_UNCONTACT = 21;
// FOLLOW на публичный канал используется; user-to-user FOLLOW legacy. DO NOT REMOVE.
public static final short CONNECTION_FOLLOW = 30;
public static final short CONNECTION_UNFOLLOW = 31;
// Родственные связи пока не используются в UI. DO NOT REMOVE.
public static final short CONNECTION_SPOUSE = 40;
public static final short CONNECTION_UNSPOUSE = 41;
@@ -101,20 +88,15 @@ public final class DatabaseInitializer {
public static final short CONNECTION_SIBLING = 54;
public static final short CONNECTION_UNSIBLING = 55;
// Legacy 60/61: новый UI не использует. DO NOT REMOVE.
public static final short CONNECTION_KNOWN_PERSON = 60;
public static final short CONNECTION_UNKNOWN_PERSON = 61;
public static final short CONNECTION_SHINE_CONFIRMED = 70;
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
// Пока не используется в UI. DO NOT REMOVE.
public static final short CONNECTION_SHINE_SEEN = 74;
public static final short CONNECTION_SHINE_UNSEEN = 75;
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
String user,
String password) throws SQLException {
@@ -190,22 +172,6 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
currentVersion = SCHEMA_VERSION_16;
}
if (currentVersion < SCHEMA_VERSION_17) {
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
currentVersion = SCHEMA_VERSION_17;
}
if (currentVersion < SCHEMA_VERSION_18) {
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
currentVersion = SCHEMA_VERSION_18;
}
if (currentVersion < SCHEMA_VERSION_19) {
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;
}
}
}
@@ -58,10 +58,10 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/**
* Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
*/
/** Добавить в близкие друзья (close friend). */
@@ -70,26 +70,18 @@ public final class MsgSubType {
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 14;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 15;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
/** Удалить из контактов. */
public static final short CONNECTION_UNCONTACT = 21;
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
/** Подписаться (follow). */
public static final short CONNECTION_FOLLOW = 30;
/** Отписаться (unfollow). */
public static final short CONNECTION_UNFOLLOW = 31;
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
/** Добавить связь "жена/муж". */
public static final short CONNECTION_SPOUSE = 40;
@@ -114,8 +106,10 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55;
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
@@ -124,19 +118,12 @@ public final class MsgSubType {
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/** Подтверждение официального аккаунта человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
/** Снятие подтверждения официального аккаунта человека. */
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
@@ -426,25 +426,6 @@ public final class BlockchainResyncCleanupDAO {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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)),
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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))
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO channel_stats_state (
owner_bch_name,
@@ -64,7 +64,6 @@ public final class DmDialogStateDAO {
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
try (PreparedStatement ps = c.prepareStatement("""
@@ -77,18 +76,16 @@ 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"),
lastMessageTimeMs,
unreadCount,
rs.getLong("last_message_time_ms"),
rs.getInt("unread_count"),
rs.getLong("last_read_receipt_time_ms"),
rs.getLong("updated_at_ms"),
lastMessageTimeMs > 0 || unreadCount > 0
true
);
byPeer.put(normKey(row.peerLogin()), row);
}
@@ -98,9 +95,6 @@ public final class DmDialogStateDAO {
for (String peer : contacts) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
}
for (String peer : friends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
}
for (String peer : closeFriends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
}
@@ -292,9 +286,6 @@ public final class DmDialogStateDAO {
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
return "friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
@@ -366,8 +357,6 @@ public final class DmDialogStateDAO {
String nextRelation = current.relationFlag();
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "close_friend";
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "friend";
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
nextRelation = "contact";
} else {
@@ -444,7 +433,7 @@ public final class DmDialogStateDAO {
private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT);
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
return "none";
}
@@ -1,35 +0,0 @@
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);
}
}
@@ -84,33 +84,6 @@ 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"));
@@ -55,7 +55,6 @@ public final class UserParamsDAO {
WHERE users_params.time_ms < excluded.time_ms
""";
int changed;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getLogin());
ps.setString(2, e.getParam());
@@ -68,12 +67,8 @@ public final class UserParamsDAO {
if (e.getSignature() != null) ps.setString(6, e.getSignature());
else ps.setNull(6, Types.VARCHAR);
changed = ps.executeUpdate();
return ps.executeUpdate();
}
if (changed > 0 && affectsFastProfile(e.getParam())) {
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
}
return changed;
}
public int upsertIfNewer(UserParamEntry e) throws SQLException {
@@ -82,32 +77,6 @@ public final class UserParamsDAO {
}
}
private static boolean affectsFastProfile(String param) {
if (param == null) return false;
return switch (param) {
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
default -> false;
};
}
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
ps.setString(1, login);
ps.execute();
}
if ("account_role".equals(param) || "shine".equals(param)) {
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
ps.setString(1, login);
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
ps.setString(1, login);
ps.execute();
}
}
}
// -------------------- SELECT --------------------
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
@@ -1,142 +0,0 @@
package shine.db.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import shine.db.MsgSubType;
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
public final class UserProfileStateDAO {
private static volatile UserProfileStateDAO instance;
private UserProfileStateDAO() {}
public static UserProfileStateDAO getInstance() {
if (instance == null) synchronized (UserProfileStateDAO.class) {
if (instance == null) instance = new UserProfileStateDAO();
}
return instance;
}
public ProfileCard get(Connection c, String login) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
return map(rs);
}
}
}
/**
* Effective outgoing social relation with priority close_friend > friend > contact > none.
*
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
* older/current blocks may address a user by blockchain name, so a direct to_login-only
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
*/
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
return "friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
int safeOffset = Math.max(0, offset);
String where = switch (String.valueOf(listType)) {
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
};
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
String personExpr = received ? "cs.login" : "cs.to_login";
String sql = """
SELECT DISTINCT ON (LOWER(%s))
%s AS person_login,
COALESCE(up.first_name,'') AS first_name,
COALESCE(up.last_name,'') AS last_name,
COALESCE(up.ava_ar,'') AS ava_ar,
up.account_role, up.shine_status,
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
CASE
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
ELSE 'none' END AS relation_type,
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
FROM connections_state cs
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
WHERE %s
ORDER BY LOWER(%s), %s
LIMIT ? OFFSET ?
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
List<RelationCard> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i=1;
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
ps.setString(i++, ownerLogin);
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(new RelationCard(
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
));
}
}
return out;
}
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
String sql;
if ("owned".equals(mode)) {
sql="""
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
""";
} else if ("following".equals(mode)) {
sql="""
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
""";
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
List<ChannelCard> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
return out;
}
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
}
@@ -1,381 +0,0 @@
BEGIN;
-- Add active FRIEND 14/15 and OFFICIAL_ACCOUNT confirmation 80/81 to connection state.
-- Legacy 60/61 remain accepted at protocol level; this migration does not reinterpret existing rows.
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 15 THEN 14
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
WHEN 51 THEN 50
WHEN 53 THEN 52
WHEN 55 THEN 54
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
WHEN 81 THEN 80
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 17, 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;
@@ -1,348 +0,0 @@
BEGIN;
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
CREATE TABLE IF NOT EXISTS user_profile_state (
login TEXT PRIMARY KEY,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
ava_ar TEXT NOT NULL DEFAULT '',
account_role TEXT,
shine_status TEXT,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
ALTER TABLE user_stats_state
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
ALTER TABLE message_stats
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_first_name TEXT := '';
v_last_name TEXT := '';
v_ava_ar TEXT := '';
v_account_role TEXT := NULL;
v_shine TEXT := NULL;
v_updated BIGINT := 0;
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
MAX(value) FILTER (WHERE param = 'account_role'),
MAX(value) FILTER (WHERE param = 'shine'),
COALESCE(MAX(time_ms), 0)
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
FROM users_params
WHERE LOWER(login) = LOWER(p_login);
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
ON CONFLICT (login) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
ava_ar = EXCLUDED.ava_ar,
account_role = EXCLUDED.account_role,
shine_status = EXCLUDED.shine_status,
updated_at_ms = EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
following_channels_count, close_friends_count, friends_count,
primary_confirmations_received_count, primary_confirmations_given_count,
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
0,
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
v_now
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
following_users_count=0,
following_channels_count=EXCLUDED.following_channels_count,
close_friends_count=EXCLUDED.close_friends_count,
friends_count=EXCLUDED.friends_count,
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
PERFORM shine_refresh_user_stats(p_login);
FOR r IN
SELECT DISTINCT x.login FROM (
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
UNION
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
) x
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
FROM reactions_state rs
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
LOOP
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_owner_login TEXT;
v_channel_type INTEGER;
v_count INTEGER := 0;
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
SELECT cn.owner_login, cn.channel_type_code
INTO v_owner_login, v_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name=p_bch_name
AND cn.channel_root_block_number=p_block_number
AND cn.channel_root_block_hash=p_block_hash
LIMIT 1;
IF v_channel_type IS NULL THEN RETURN; END IF;
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
INTO v_count
FROM connections_state cs
WHERE cs.rel_type=30
AND cs.to_bch_name=p_bch_name
AND cs.to_block_number=p_block_number
AND cs.to_block_hash=p_block_hash;
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
owner_login=EXCLUDED.owner_login,
channel_type_code=EXCLUDED.channel_type_code,
subscribers_count=EXCLUDED.subscribers_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
-- Replace incremental channel-name counter with idempotent recomputation.
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.channel_type_code = 1 THEN
PERFORM shine_refresh_user_stats(NEW.owner_login);
END IF;
RETURN NEW;
END;
$$;
-- Rebuild connection state without incremental user counters; then recompute affected users.
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
ELSE
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
END IF;
PERFORM shine_refresh_user_stats(NEW.login);
PERFORM shine_refresh_user_stats(resolved_login);
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE previous_sub_type INTEGER;
BEGIN
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
UPDATE message_stats SET likes_count=likes_count+1,
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
END IF;
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
RETURN NEW;
END;
$$;
-- Initial projection/rebuild.
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
END LOOP;
END $$;
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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));
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,18,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;
@@ -1,83 +0,0 @@
BEGIN;
-- v19: skip user_stats refresh for logins that are absent from solana_user_pda_current.
-- This keeps bootstrap resilient to legacy rows that are still present in blocks.
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
IF NOT EXISTS (
SELECT 1
FROM solana_user_pda_current
WHERE login = p_login
LIMIT 1
) THEN
RETURN;
END IF;
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
following_channels_count, close_friends_count, friends_count,
primary_confirmations_received_count, primary_confirmations_given_count,
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
0,
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
v_now
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
following_users_count=0,
following_channels_count=EXCLUDED.following_channels_count,
close_friends_count=EXCLUDED.close_friends_count,
friends_count=EXCLUDED.friends_count,
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
PERFORM shine_refresh_user_stats(p_login);
FOR r IN
SELECT DISTINCT x.login FROM (
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
UNION
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
) x
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
END;
$$;
COMMIT;
@@ -1,19 +0,0 @@
-- 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, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 16, 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', 'event')),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
@@ -1113,7 +1113,7 @@ BEGIN
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
@@ -1288,7 +1288,6 @@ BEGIN
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 15 THEN 14
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
@@ -1298,7 +1297,6 @@ BEGIN
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
WHEN 81 THEN 80
ELSE NULL
END;
@@ -1646,371 +1644,4 @@ AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
-- v18 integrated fresh-schema definitions
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
CREATE TABLE IF NOT EXISTS user_profile_state (
login TEXT PRIMARY KEY,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
ava_ar TEXT NOT NULL DEFAULT '',
account_role TEXT,
shine_status TEXT,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
ALTER TABLE user_stats_state
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
ALTER TABLE message_stats
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_first_name TEXT := '';
v_last_name TEXT := '';
v_ava_ar TEXT := '';
v_account_role TEXT := NULL;
v_shine TEXT := NULL;
v_updated BIGINT := 0;
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
MAX(value) FILTER (WHERE param = 'account_role'),
MAX(value) FILTER (WHERE param = 'shine'),
COALESCE(MAX(time_ms), 0)
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
FROM users_params
WHERE LOWER(login) = LOWER(p_login);
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
ON CONFLICT (login) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
ava_ar = EXCLUDED.ava_ar,
account_role = EXCLUDED.account_role,
shine_status = EXCLUDED.shine_status,
updated_at_ms = EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
IF NOT EXISTS (
SELECT 1
FROM solana_user_pda_current
WHERE login = p_login
LIMIT 1
) THEN
RETURN;
END IF;
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
following_channels_count, close_friends_count, friends_count,
primary_confirmations_received_count, primary_confirmations_given_count,
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
0,
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
v_now
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
following_users_count=0,
following_channels_count=EXCLUDED.following_channels_count,
close_friends_count=EXCLUDED.close_friends_count,
friends_count=EXCLUDED.friends_count,
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
PERFORM shine_refresh_user_stats(p_login);
FOR r IN
SELECT DISTINCT x.login FROM (
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
UNION
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
) x
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
FROM reactions_state rs
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
LOOP
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_owner_login TEXT;
v_channel_type INTEGER;
v_count INTEGER := 0;
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
BEGIN
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
SELECT cn.owner_login, cn.channel_type_code
INTO v_owner_login, v_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name=p_bch_name
AND cn.channel_root_block_number=p_block_number
AND cn.channel_root_block_hash=p_block_hash
LIMIT 1;
IF v_channel_type IS NULL THEN RETURN; END IF;
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
INTO v_count
FROM connections_state cs
WHERE cs.rel_type=30
AND cs.to_bch_name=p_bch_name
AND cs.to_block_number=p_block_number
AND cs.to_block_hash=p_block_hash;
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
owner_login=EXCLUDED.owner_login,
channel_type_code=EXCLUDED.channel_type_code,
subscribers_count=EXCLUDED.subscribers_count,
updated_at_ms=EXCLUDED.updated_at_ms;
END;
$$;
-- Replace incremental channel-name counter with idempotent recomputation.
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.channel_type_code = 1 THEN
PERFORM shine_refresh_user_stats(NEW.owner_login);
END IF;
RETURN NEW;
END;
$$;
-- Rebuild connection state without incremental user counters; then recompute affected users.
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
ELSE
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
END IF;
PERFORM shine_refresh_user_stats(NEW.login);
PERFORM shine_refresh_user_stats(resolved_login);
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE previous_sub_type INTEGER;
BEGIN
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
UPDATE message_stats SET likes_count=likes_count+1,
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
END IF;
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
RETURN NEW;
END;
$$;
-- Initial projection/rebuild.
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
END $$;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
END LOOP;
END $$;
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
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,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;
@@ -88,19 +88,13 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalD
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request;
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request;
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;
@@ -213,11 +207,8 @@ public final class JsonHandlerRegistry {
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
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()),
@@ -304,11 +295,8 @@ public final class JsonHandlerRegistry {
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
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),
@@ -563,13 +563,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setEditedByBlockNumber(be.getToBlockNumber());
}
if (block.body instanceof ConnectionBody connectionBody) {
String votingError = validateConnectionVotingRights(login, block.subType & 0xFFFF, connectionBody.toLogin());
if (votingError != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, votingError, serverLastNum, serverLastHashHex);
}
}
UserParamEntry upsertedParam = null;
if (block.body instanceof UserParamBody upBody) {
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
@@ -616,31 +609,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
/* ====================== Helpers ====================================== */
/* ===================================================================== */
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
if (targetLogin == null || targetLogin.isBlank()) return "bad_connection_target";
String sql = subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
? "SELECT shine_is_primary(?), shine_target_accepts_primary_confirmation(?)"
: "SELECT (shine_is_primary(?) AND shine_is_shining(?)), shine_target_accepts_shine_confirmation(?)";
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
if (subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)) {
ps.setString(1, actorLogin); ps.setString(2, targetLogin);
} else {
ps.setString(1, actorLogin); ps.setString(2, actorLogin); ps.setString(3, targetLogin);
}
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next() || !rs.getBoolean(1)) return subType == 80 ? "primary_vote_not_allowed" : "shine_vote_not_allowed";
if (!rs.getBoolean(2)) return subType == 80 ? "target_non_voting" : "target_shine_not_interested";
}
return null;
} catch (Exception e) {
log.warn("Connection voting permission check failed actor={} target={} subtype={}", actorLogin, targetLogin, subType, e);
return "voting_permission_check_failed";
}
}
private static byte[] decodeBase64(String b64) {
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
return Base64Ws.decode(b64);
@@ -916,23 +884,16 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
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;
}
// Connection notifications are intentionally modeled as a generic kind.
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
// can reuse the same notification kind and expose their code via sourceMsgSubType.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
&& block.body instanceof ConnectionBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("connection");
entry.setSourceText("");
return entry;
}
return null;
@@ -138,17 +138,6 @@ public final class ChannelNamesStateBootstrapper {
dao.insertAll(c, entries);
applyMetaUpdates(c, dao, entries, skipped);
// v18: counters are a projection, so rebuild them from current state after a full channel-name bootstrap.
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("""
SELECT shine_refresh_channel_follow_stats(owner_bch_name, channel_root_block_number, channel_root_block_hash)
FROM channel_names_state
WHERE channel_type_code = 1
""")) {
ps.execute();
}
c.commit();
log.info("channel_names_state bootstrapped: {}", entries.size());
if (!conflicts.isEmpty()) {
@@ -342,10 +342,8 @@ final class ChannelsReadSupport {
}
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
int likesCount = 0;
int primaryLikesCount = 0;
int shiningLikesCount = 0;
int repliesCount = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
@@ -354,8 +352,6 @@ final class ChannelsReadSupport {
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
likesCount = rs.getInt("likes_count");
primaryLikesCount = rs.getInt("primary_likes_count");
shiningLikesCount = rs.getInt("shining_likes_count");
repliesCount = rs.getInt("replies_count");
}
}
@@ -382,7 +378,7 @@ final class ChannelsReadSupport {
}
}
}
return new int[] {likesCount, repliesCount, ratingsCount, primaryLikesCount, shiningLikesCount};
return new int[] {likesCount, repliesCount, ratingsCount};
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
@@ -169,8 +169,6 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]);
item.setPrimaryLikesCount(stats[3]);
item.setShiningLikesCount(stats[4]);
item.setRepliesCount(stats[1]);
item.setRatingsCount(stats[2]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
@@ -242,8 +242,6 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setPrimaryLikesCount(stats[3]);
node.setShiningLikesCount(stats[4]);
node.setRepliesCount(stats[1]);
node.setRatingsCount(stats[2]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
@@ -82,10 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
row.setUnreadCount(ownChannel
? 0
: ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
@@ -139,8 +139,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private long createdAtMs;
private String text;
private int likesCount;
private int primaryLikesCount;
private int shiningLikesCount;
private boolean likedByMe;
private int repliesCount;
private int ratingsCount;
@@ -193,10 +191,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getLikesCount() { return likesCount; }
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
public int getPrimaryLikesCount() { return primaryLikesCount; }
public void setPrimaryLikesCount(int primaryLikesCount) { this.primaryLikesCount = primaryLikesCount; }
public int getShiningLikesCount() { return shiningLikesCount; }
public void setShiningLikesCount(int shiningLikesCount) { this.shiningLikesCount = shiningLikesCount; }
public boolean isLikedByMe() { return likedByMe; }
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
@@ -46,10 +46,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> outCloseFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
List<String> inCloseFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
List<String> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
@@ -62,21 +60,18 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
// Legacy 60/61 are accepted by the protocol but intentionally not surfaced by current UI.
List<String> outKnownPersons = List.of();
List<String> inKnownPersons = List.of();
List<String> outOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, true);
List<String> inOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, false);
List<String> outShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, true);
List<String> inShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, false);
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
allLogins.add(canonicalLogin);
addAllLogins(allLogins, outFriends, inFriends, outCloseFriends, inCloseFriends, outContacts, inContacts, outFollows, inFollows,
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
outKnownPersons, inKnownPersons, outOfficialAccounts, inOfficialAccounts, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
@@ -91,8 +86,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setLogin(canonicalLogin);
resp.setOutFriends(outFriends);
resp.setInFriends(inFriends);
resp.setOutCloseFriends(outCloseFriends);
resp.setInCloseFriends(inCloseFriends);
resp.setOutContacts(outContacts);
resp.setInContacts(inContacts);
resp.setOutFollows(outFollows);
@@ -107,8 +100,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setInSiblings(inSiblings);
resp.setOutKnownPersons(outKnownPersons);
resp.setInKnownPersons(inKnownPersons);
resp.setOutOfficialAccounts(outOfficialAccounts);
resp.setInOfficialAccounts(inOfficialAccounts);
resp.setOutShineConfirmed(outShineConfirmed);
resp.setInShineConfirmed(inShineConfirmed);
resp.setOutShineSeen(outShineSeen);
@@ -117,49 +108,11 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
resp.setSpouses(toRelativeItems(spouseLogins, metaByLogin));
Map<String, String> relationTypes = buildEffectiveRelationTypes(outContacts, outFriends, outCloseFriends);
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin, relationTypes, outOfficialAccounts, outShineConfirmed));
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin));
return resp;
}
}
private List<String> listEffectiveConfirmation(Connection c, String login, int relType, boolean outgoing) throws Exception {
String sql;
if (relType == 80) {
sql = outgoing ? """
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
ORDER BY peer
""" : """
SELECT DISTINCT cs.login AS peer FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
ORDER BY peer
""";
} else {
sql = outgoing ? """
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
AND shine_target_accepts_shine_confirmation(cs.to_login)
ORDER BY peer
""" : """
SELECT DISTINCT cs.login AS peer FROM connections_state cs
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
AND shine_target_accepts_shine_confirmation(cs.to_login)
ORDER BY peer
""";
}
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(rs.getString("peer")); }
}
return out;
}
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su")
+ " WHERE LOWER(login) = LOWER(?) LIMIT 1";
@@ -207,54 +160,47 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private Map<String, UserMeta> loadUserMeta(Connection c, Set<String> logins) throws Exception {
Map<String, UserMeta> out = new HashMap<>();
if (logins == null || logins.isEmpty()) return out;
String[] placeholders = new String[logins.size()];
for (int i=0;i<placeholders.length;i++) placeholders[i]="?";
for (int i = 0; i < placeholders.length; i += 1) placeholders[i] = "?";
String sql = """
SELECT su.login,
COALESCE(ups.first_name,'') first_name,
COALESCE(ups.last_name,'') last_name,
ups.account_role, ups.shine_status, COALESCE(ups.ava_ar,'') avatar_value,
COALESCE(ust.primary_confirmations_received_count,0) primary_confirmations_count,
COALESCE(ust.shine_confirmations_received_count,0) shine_confirmations_count,
MAX(CASE WHEN up.param='gender' THEN up.value END) gender_value
SELECT
su.login AS login,
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
FROM %s
LEFT JOIN user_profile_state ups ON LOWER(ups.login)=LOWER(su.login)
LEFT JOIN user_stats_state ust ON LOWER(ust.login)=LOWER(su.login)
LEFT JOIN users_params up ON LOWER(up.login)=LOWER(su.login) AND up.param='gender'
LEFT JOIN users_params up
ON LOWER(up.login) = LOWER(su.login)
AND up.param IN ('gender', 'official', 'shine', 'ava')
WHERE LOWER(su.login) IN (%s)
GROUP BY su.login, ups.first_name, ups.last_name, ups.account_role, ups.shine_status, ups.ava_ar,
ust.primary_confirmations_received_count, ust.shine_confirmations_received_count
GROUP BY su.login
ORDER BY su.login
""".formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders));
try(PreparedStatement ps=c.prepareStatement(sql)){
int i=1; for(String login:logins) ps.setString(i++,normKey(login));
try(ResultSet rs=ps.executeQuery()){while(rs.next()){
String login=rs.getString("login"); if(login==null||login.isBlank()) continue;
UserMeta meta=new UserMeta(); meta.firstName=rs.getString("first_name"); meta.lastName=rs.getString("last_name");
meta.accountRole=rs.getString("account_role"); meta.shineStatus=rs.getString("shine_status");
meta.gender=normalizeGender(rs.getString("gender_value")); meta.official="primary".equals(meta.accountRole); meta.shine="shining".equals(meta.shineStatus);
meta.primaryConfirmationsCount=rs.getInt("primary_confirmations_count"); meta.shineConfirmationsCount=rs.getInt("shine_confirmations_count");
meta.avatarAr=extractArAvatarTxId(rs.getString("avatar_value")); out.put(normKey(login),meta);
}}
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
for (String login : logins) {
ps.setString(i, normKey(login));
i += 1;
}
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String login = rs.getString("login");
if (login == null || login.isBlank()) continue;
UserMeta meta = new UserMeta();
meta.gender = normalizeGender(rs.getString("gender_value"));
meta.official = parseToggle(rs.getString("official_value"));
meta.shine = parseToggle(rs.getString("shine_value"));
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
out.put(normKey(login), meta);
}
}
}
return out;
}
private Map<String,String> buildEffectiveRelationTypes(List<String> contacts,List<String> friends,List<String> closeFriends){
Map<String,String> out=new HashMap<>();
if(contacts!=null) for(String v:contacts) out.put(normKey(v),"contact");
if(friends!=null) for(String v:friends) out.put(normKey(v),"friend");
if(closeFriends!=null) for(String v:closeFriends) out.put(normKey(v),"close_friend");
return out;
}
private boolean listContainsLogin(List<String> list, String login) {
if (list == null || login == null) return false;
String key = normKey(login);
for (String value : list) if (key.equals(normKey(value))) return true;
return false;
}
private boolean parseToggle(String rawValue) {
String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase();
return "1".equals(v) || "yes".equals(v) || "true".equals(v) || "on".equals(v);
@@ -323,10 +269,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private List<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
Set<String> logins,
Map<String, UserMeta> metaByLogin,
Map<String, String> relationTypes,
List<String> primaryConfirmedLogins,
List<String> shineConfirmedLogins
Map<String, UserMeta> metaByLogin
) {
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> items = new ArrayList<>();
if (logins == null) return items;
@@ -341,15 +284,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
Net_GetUserConnectionsGraph_Response.UserMarkItem it = new Net_GetUserConnectionsGraph_Response.UserMarkItem();
it.setLogin(clean);
it.setFirstName(meta == null ? "" : meta.firstName);
it.setLastName(meta == null ? "" : meta.lastName);
it.setAccountRole(meta == null ? null : meta.accountRole);
it.setShineStatus(meta == null ? null : meta.shineStatus);
it.setRelationType(relationTypes == null ? "none" : relationTypes.getOrDefault(normKey(clean), "none"));
it.setPrimaryConfirmed(listContainsLogin(primaryConfirmedLogins, clean));
it.setShineConfirmed(listContainsLogin(shineConfirmedLogins, clean));
it.setPrimaryConfirmationsCount(meta == null ? 0 : meta.primaryConfirmationsCount);
it.setShineConfirmationsCount(meta == null ? 0 : meta.shineConfirmationsCount);
it.setOfficial(official);
it.setShine(shine);
it.setOfficialLabel(official ? "официальный" : "неофициальный");
@@ -362,12 +296,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private static final class UserMeta {
private String gender = "unknown";
private String firstName = "";
private String lastName = "";
private String accountRole = null;
private String shineStatus = null;
private int primaryConfirmationsCount = 0;
private int shineConfirmationsCount = 0;
private boolean official = false;
private boolean shine = false;
private String avatarAr = null;
@@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DmDialogStateDAO;
import shine.db.dao.UserProfileStateDAO;
import java.sql.Connection;
import java.util.ArrayList;
@@ -30,25 +29,18 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin());
resp.setDialogs(toDialogItems(c, dialogs));
resp.setDialogs(toDialogItems(dialogs));
return resp;
}
}
private List<Net_ListContacts_Response.DialogItem> toDialogItems(Connection c, List<DmDialogStateDAO.DialogSummary> dialogs) throws Exception {
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
if (dialogs == null) return items;
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
item.setPeerLogin(dialog.peerLogin());
item.setRelationFlag(dialog.relationFlag());
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, dialog.peerLogin());
item.setFirstName(card.firstName());
item.setLastName(card.lastName());
item.setAvatarAr(card.avatarAr());
item.setAvatar(parseAvatar(card.avatarAr()));
item.setAccountRole(card.accountRole());
item.setShineStatus(card.shineStatus());
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
item.setUnreadCount(dialog.unreadCount());
@@ -57,14 +49,4 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
}
return items;
}
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
String raw = value == null ? "" : value.trim();
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
if (ar.find()) out.setAr(ar.group(1));
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
return out;
}
}
@@ -9,8 +9,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private String login;
private List<String> outFriends = new ArrayList<>();
private List<String> inFriends = new ArrayList<>();
private List<String> outCloseFriends = new ArrayList<>();
private List<String> inCloseFriends = new ArrayList<>();
private List<String> outContacts = new ArrayList<>();
private List<String> inContacts = new ArrayList<>();
private List<String> outFollows = new ArrayList<>();
@@ -25,8 +23,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private List<String> inSiblings = new ArrayList<>();
private List<String> outKnownPersons = new ArrayList<>();
private List<String> inKnownPersons = new ArrayList<>();
private List<String> outOfficialAccounts = new ArrayList<>();
private List<String> inOfficialAccounts = new ArrayList<>();
private List<String> outShineConfirmed = new ArrayList<>();
private List<String> inShineConfirmed = new ArrayList<>();
private List<String> outShineSeen = new ArrayList<>();
@@ -62,15 +58,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public static class UserMarkItem {
private String login;
private String firstName;
private String lastName;
private String accountRole;
private String shineStatus;
private String relationType;
private boolean primaryConfirmed;
private boolean shineConfirmed;
private int primaryConfirmationsCount;
private int shineConfirmationsCount;
private boolean official;
private boolean shine;
private String officialLabel;
@@ -79,24 +66,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getFirstName() { return firstName; }
public void setFirstName(String v) { this.firstName = v; }
public String getLastName() { return lastName; }
public void setLastName(String v) { this.lastName = v; }
public String getAccountRole() { return accountRole; }
public void setAccountRole(String v) { this.accountRole = v; }
public String getShineStatus() { return shineStatus; }
public void setShineStatus(String v) { this.shineStatus = v; }
public String getRelationType() { return relationType; }
public void setRelationType(String v) { this.relationType = v; }
public boolean isPrimaryConfirmed() { return primaryConfirmed; }
public void setPrimaryConfirmed(boolean v) { this.primaryConfirmed = v; }
public boolean isShineConfirmed() { return shineConfirmed; }
public void setShineConfirmed(boolean v) { this.shineConfirmed = v; }
public int getPrimaryConfirmationsCount() { return primaryConfirmationsCount; }
public void setPrimaryConfirmationsCount(int v) { this.primaryConfirmationsCount = v; }
public int getShineConfirmationsCount() { return shineConfirmationsCount; }
public void setShineConfirmationsCount(int v) { this.shineConfirmationsCount = v; }
public boolean isOfficial() { return official; }
public void setOfficial(boolean official) { this.official = official; }
public boolean isShine() { return shine; }
@@ -115,10 +84,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
public List<String> getInFriends() { return inFriends; }
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
public List<String> getOutCloseFriends() { return outCloseFriends; }
public void setOutCloseFriends(List<String> outCloseFriends) { this.outCloseFriends = outCloseFriends; }
public List<String> getInCloseFriends() { return inCloseFriends; }
public void setInCloseFriends(List<String> inCloseFriends) { this.inCloseFriends = inCloseFriends; }
public List<String> getOutContacts() { return outContacts; }
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
public List<String> getInContacts() { return inContacts; }
@@ -147,10 +112,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
public List<String> getInKnownPersons() { return inKnownPersons; }
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
public List<String> getOutOfficialAccounts() { return outOfficialAccounts; }
public void setOutOfficialAccounts(List<String> outOfficialAccounts) { this.outOfficialAccounts = outOfficialAccounts; }
public List<String> getInOfficialAccounts() { return inOfficialAccounts; }
public void setInOfficialAccounts(List<String> inOfficialAccounts) { this.inOfficialAccounts = inOfficialAccounts; }
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
public List<String> getInShineConfirmed() { return inShineConfirmed; }
@@ -17,12 +17,6 @@ public class Net_ListContacts_Response extends Net_Response {
public static class DialogItem {
private String peerLogin;
private String relationFlag;
private String firstName;
private String lastName;
private String avatarAr;
private Avatar avatar;
private String accountRole;
private String shineStatus;
private String lastMessageBlobB64;
private long lastMessageTimeMs;
private int unreadCount;
@@ -32,18 +26,6 @@ public class Net_ListContacts_Response extends Net_Response {
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
public String getRelationFlag() { return relationFlag; }
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getAvatarAr() { return avatarAr; }
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
public Avatar getAvatar() { return avatar; }
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
public String getAccountRole() { return accountRole; }
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
public String getShineStatus() { return shineStatus; }
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
@@ -53,13 +35,4 @@ public class Net_ListContacts_Response extends Net_Response {
public boolean isHasDialog() { return hasDialog; }
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
}
public static class Avatar {
private String ar;
private String sha256Hex;
public String getAr() { return ar; }
public void setAr(String ar) { this.ar = ar; }
public String getSha256Hex() { return sha256Hex; }
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
}
}
@@ -1,27 +1,94 @@
package server.logic.ws_protocol.JSON.handlers.notifications;
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.*; 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.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.UserNotificationEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); 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();}
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);
}
}
@@ -1,29 +0,0 @@
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()); }
}
}
@@ -1,35 +0,0 @@
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"; }
}
@@ -3,10 +3,8 @@ 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; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
private Boolean countsOnly;
private Integer limit;
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; }
}
@@ -8,23 +8,12 @@ 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; }
@@ -1,3 +0,0 @@
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;} }
@@ -1,3 +0,0 @@
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;} }
@@ -1,3 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile;
import server.logic.ws_protocol.JSON.*;import server.logic.ws_protocol.JSON.entyties.*;import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;import server.logic.ws_protocol.JSON.handlers.profile.entyties.*;import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;import server.logic.ws_protocol.WireCodes;import shine.db.dao.UserProfileStateDAO;import java.sql.*;import java.util.*;
public class Net_ListUserProfileChannels_Handler implements JsonMessageHandler {public Net_Response handle(Net_Request base,ConnectionContext ctx)throws Exception{var req=(Net_ListUserProfileChannels_Request)base;if(req.getLogin()==null||req.getLogin().isBlank())return NetExceptionResponseFactory.error(req,400,"BAD_FIELDS","login required");try(Connection c=shine.db.DbController.getInstance().getConnection()){var rows=UserProfileStateDAO.getInstance().listPublicChannels(c,req.getLogin(),req.getMode(),req.getLimit()==null?100:req.getLimit(),req.getOffset()==null?0:req.getOffset());var resp=new Net_ListUserProfileChannels_Response();resp.setOp(req.getOp());resp.setRequestId(req.getRequestId());resp.setStatus(WireCodes.Status.OK);resp.setLogin(req.getLogin());resp.setMode(req.getMode());List<Net_ListUserProfileChannels_Response.ChannelItem> items=new ArrayList<>();for(var r:rows){var i=new Net_ListUserProfileChannels_Response.ChannelItem();i.setOwnerLogin(r.ownerLogin());i.setSlug(r.slug());i.setDisplayName(r.displayName());i.setAvatarAr(r.avatarAr());i.setOwnerBlockchainName(r.ownerBlockchainName());i.setRootBlockNumber(r.rootBlockNumber());i.setRootBlockHashHex(r.rootBlockHashHex());items.add(i);}resp.setChannels(items);return resp;}}}
@@ -1,14 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile;
import server.logic.ws_protocol.JSON.*; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.profile.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.UserProfileStateDAO; import java.sql.*; import java.util.*;
public class Net_ListUserProfileRelations_Handler implements JsonMessageHandler {
public Net_Response handle(Net_Request base, ConnectionContext ctx)throws Exception{
var req=(Net_ListUserProfileRelations_Request)base; if(req.getLogin()==null||req.getLogin().isBlank()) return NetExceptionResponseFactory.error(req,400,"BAD_FIELDS","login required");
try(Connection c=shine.db.DbController.getInstance().getConnection()){
var rows=UserProfileStateDAO.getInstance().listRelations(c,req.getLogin(),req.getListType(),req.getLimit()==null?100:req.getLimit(),req.getOffset()==null?0:req.getOffset());
var resp=new Net_ListUserProfileRelations_Response(); resp.setOp(req.getOp());resp.setRequestId(req.getRequestId());resp.setStatus(WireCodes.Status.OK);resp.setLogin(req.getLogin());resp.setListType(req.getListType());
List<Net_ListUserProfileRelations_Response.UserItem> items=new ArrayList<>(); for(var r:rows){var i=new Net_ListUserProfileRelations_Response.UserItem();i.setLogin(r.login());i.setFirstName(r.firstName());i.setLastName(r.lastName());i.setAvatarAr(r.avatarAr());i.setAccountRole(r.accountRole());i.setShineStatus(r.shineStatus());i.setRelationType(r.relationType());i.setPrimaryConfirmed(r.primaryConfirmed());i.setShineConfirmed(r.shineConfirmed());i.setPrimaryConfirmationsCount(r.primaryConfirmationsCount());i.setShineConfirmationsCount(r.shineConfirmationsCount());items.add(i);} resp.setUsers(items);return resp;
}
}
}
@@ -1,3 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListUserProfileChannels_Request extends Net_Request {private String login,mode;private Integer limit,offset;public String getLogin(){return login;}public void setLogin(String v){login=v;}public String getMode(){return mode;}public void setMode(String v){mode=v;}public Integer getLimit(){return limit;}public void setLimit(Integer v){limit=v;}public Integer getOffset(){return offset;}public void setOffset(Integer v){offset=v;}}
@@ -1,3 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;import java.util.*;
public class Net_ListUserProfileChannels_Response extends Net_Response {private String login,mode;private List<ChannelItem> channels=new ArrayList<>();public String getLogin(){return login;}public void setLogin(String v){login=v;}public String getMode(){return mode;}public void setMode(String v){mode=v;}public List<ChannelItem> getChannels(){return channels;}public void setChannels(List<ChannelItem> v){channels=v;}public static class ChannelItem{private String ownerLogin,slug,displayName,avatarAr,ownerBlockchainName,rootBlockHashHex;private int rootBlockNumber;public String getOwnerLogin(){return ownerLogin;}public void setOwnerLogin(String v){ownerLogin=v;}public String getSlug(){return slug;}public void setSlug(String v){slug=v;}public String getDisplayName(){return displayName;}public void setDisplayName(String v){displayName=v;}public String getAvatarAr(){return avatarAr;}public void setAvatarAr(String v){avatarAr=v;}public String getOwnerBlockchainName(){return ownerBlockchainName;}public void setOwnerBlockchainName(String v){ownerBlockchainName=v;}public int getRootBlockNumber(){return rootBlockNumber;}public void setRootBlockNumber(int v){rootBlockNumber=v;}public String getRootBlockHashHex(){return rootBlockHashHex;}public void setRootBlockHashHex(String v){rootBlockHashHex=v;}}}
@@ -1,9 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListUserProfileRelations_Request extends Net_Request {
private String login; private String listType; private Integer limit; private Integer offset;
public String getLogin(){return login;} public void setLogin(String v){login=v;}
public String getListType(){return listType;} public void setListType(String v){listType=v;}
public Integer getLimit(){return limit;} public void setLimit(Integer v){limit=v;}
public Integer getOffset(){return offset;} public void setOffset(Integer v){offset=v;}
}
@@ -1,24 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.*;
public class Net_ListUserProfileRelations_Response extends Net_Response {
private String login; private String listType; private List<UserItem> users=new ArrayList<>();
public String getLogin(){return login;} public void setLogin(String v){login=v;}
public String getListType(){return listType;} public void setListType(String v){listType=v;}
public List<UserItem> getUsers(){return users;} public void setUsers(List<UserItem> v){users=v;}
public static class UserItem {
private String login,firstName,lastName,avatarAr,accountRole,shineStatus,relationType;
private boolean primaryConfirmed,shineConfirmed; private int primaryConfirmationsCount,shineConfirmationsCount;
public String getLogin(){return login;} public void setLogin(String v){login=v;}
public String getFirstName(){return firstName;} public void setFirstName(String v){firstName=v;}
public String getLastName(){return lastName;} public void setLastName(String v){lastName=v;}
public String getAvatarAr(){return avatarAr;} public void setAvatarAr(String v){avatarAr=v;}
public String getAccountRole(){return accountRole;} public void setAccountRole(String v){accountRole=v;}
public String getShineStatus(){return shineStatus;} public void setShineStatus(String v){shineStatus=v;}
public String getRelationType(){return relationType;} public void setRelationType(String v){relationType=v;}
public boolean isPrimaryConfirmed(){return primaryConfirmed;} public void setPrimaryConfirmed(boolean v){primaryConfirmed=v;}
public boolean isShineConfirmed(){return shineConfirmed;} public void setShineConfirmed(boolean v){shineConfirmed=v;}
public int getPrimaryConfirmationsCount(){return primaryConfirmationsCount;} public void setPrimaryConfirmationsCount(int v){primaryConfirmationsCount=v;}
public int getShineConfirmationsCount(){return shineConfirmationsCount;} public void setShineConfirmationsCount(int v){shineConfirmationsCount=v;}
}
}
@@ -136,21 +136,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setFollowingUsersCount(0);
resp.setFollowingChannelsCount(0);
resp.setCloseFriendsCount(0);
resp.setFriendsCount(0);
resp.setPrimaryConfirmationsReceivedCount(0);
resp.setPrimaryConfirmationsGivenCount(0);
resp.setShineConfirmationsReceivedCount(0);
resp.setShineConfirmationsGivenCount(0);
String sql = """
SELECT owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
friends_count,
primary_confirmations_received_count,
primary_confirmations_given_count,
shine_confirmations_received_count,
shine_confirmations_given_count
close_friends_count
FROM user_stats_state
WHERE login = ?
LIMIT 1
@@ -166,11 +156,6 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
resp.setFriendsCount(rs.getInt("friends_count"));
resp.setPrimaryConfirmationsReceivedCount(rs.getInt("primary_confirmations_received_count"));
resp.setPrimaryConfirmationsGivenCount(rs.getInt("primary_confirmations_given_count"));
resp.setShineConfirmationsReceivedCount(rs.getInt("shine_confirmations_received_count"));
resp.setShineConfirmationsGivenCount(rs.getInt("shine_confirmations_given_count"));
}
} catch (Exception e) {
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
@@ -47,11 +47,6 @@ public class Net_GetUser_Response extends Net_Response {
private Integer followingUsersCount;
private Integer followingChannelsCount;
private Integer closeFriendsCount;
private Integer friendsCount;
private Integer primaryConfirmationsReceivedCount;
private Integer primaryConfirmationsGivenCount;
private Integer shineConfirmationsReceivedCount;
private Integer shineConfirmationsGivenCount;
public Boolean getExists() { return exists; }
public void setExists(Boolean exists) { this.exists = exists; }
@@ -94,15 +89,5 @@ public class Net_GetUser_Response extends Net_Response {
public Integer getCloseFriendsCount() { return closeFriendsCount; }
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
public Integer getFriendsCount() { return friendsCount; }
public void setFriendsCount(Integer friendsCount) { this.friendsCount = friendsCount; }
public Integer getPrimaryConfirmationsReceivedCount() { return primaryConfirmationsReceivedCount; }
public void setPrimaryConfirmationsReceivedCount(Integer v) { this.primaryConfirmationsReceivedCount = v; }
public Integer getPrimaryConfirmationsGivenCount() { return primaryConfirmationsGivenCount; }
public void setPrimaryConfirmationsGivenCount(Integer v) { this.primaryConfirmationsGivenCount = v; }
public Integer getShineConfirmationsReceivedCount() { return shineConfirmationsReceivedCount; }
public void setShineConfirmationsReceivedCount(Integer v) { this.shineConfirmationsReceivedCount = v; }
public Integer getShineConfirmationsGivenCount() { return shineConfirmationsGivenCount; }
public void setShineConfirmationsGivenCount(Integer v) { this.shineConfirmationsGivenCount = v; }
}
@@ -11,10 +11,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserProfileStateDAO;
import shine.db.DbController;
import java.sql.Connection;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
@@ -78,21 +74,6 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
resp.setLimit(limit);
resp.setHasMore(hasMore);
// Return a normalized peer card together with the dialog. This makes the
// chat header self-contained even when /chat/<login> is opened directly.
try (Connection c = DbController.getInstance().getConnection()) {
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin);
Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard();
peer.setLogin(peerLogin);
peer.setFirstName(card.firstName());
peer.setLastName(card.lastName());
peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin));
peer.setAccountRole(card.accountRole());
peer.setShineStatus(card.shineStatus());
peer.setAvatar(parseAvatar(card.avatarAr()));
resp.setPeer(peer);
}
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
for (SignedMessageEntry entry : page) {
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
@@ -127,21 +108,4 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) {
Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar();
String raw = value == null ? "" : value.trim();
// Do not call Matcher.find() twice on the same matcher: the first successful
// call advances it and the second one can make a perfectly valid avatar vanish.
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
if (ar.find()) {
out.setAr(ar.group(1));
}
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
if (sha.find()) {
out.setSha256Hex(sha.group(1).toLowerCase());
}
return out;
}
}
@@ -12,7 +12,6 @@ public class Net_GetDirectMessages_Response extends Net_Response {
private boolean hasMore;
private Long nextBeforeTimeMs;
private String nextBeforeMessageKey;
private PeerCard peer;
private List<MessageItem> messages = new ArrayList<>();
public String getLogin() { return login; }
@@ -27,46 +26,9 @@ public class Net_GetDirectMessages_Response extends Net_Response {
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
public PeerCard getPeer() { return peer; }
public void setPeer(PeerCard peer) { this.peer = peer; }
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public static class PeerCard {
private String login;
private String firstName;
private String lastName;
private Avatar avatar;
private String relationType;
private String accountRole;
private String shineStatus;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public Avatar getAvatar() { return avatar; }
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
public String getRelationType() { return relationType; }
public void setRelationType(String relationType) { this.relationType = relationType; }
public String getAccountRole() { return accountRole; }
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
public String getShineStatus() { return shineStatus; }
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
}
public static class Avatar {
private String ar;
private String sha256Hex;
public String getAr() { return ar; }
public void setAr(String ar) { this.ar = ar; }
public String getSha256Hex() { return sha256Hex; }
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
}
public static class MessageItem {
private String messageKey;
private String baseKey;
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.0
server.version=1.10.0
client.version=1.10.0
server.version=1.8.0
+2
View File
@@ -1,3 +1,5 @@
import org.bouncycastle.util.Properties
plugins {
id 'java'
id 'application'
-300
View File
@@ -1,300 +0,0 @@
#!/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}')"
+2 -31
View File
@@ -24,9 +24,8 @@
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`.
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
---
@@ -417,31 +416,3 @@
- `limit_too_large`
- `channel_name_already_exists`
- `internal_error`
---
## Profile public-channel lists (v18)
### `ListUserProfileChannels`
Returns lightweight public-channel cards for profile counters. `mode` is `owned` or `following`; only `channel_type_code=1` is returned.
```json
{
"op": "ListUserProfileChannels",
"requestId": "profile-ch-1",
"payload": { "login": "Alice", "mode": "owned", "limit": 100, "offset": 0 }
}
```
Channel items contain `ownerLogin`, `slug`, `displayName`, `avatarAr`, `ownerBlockchainName`, `rootBlockNumber`, and `rootBlockHashHex`.
## Qualified likes (v18)
Channel/message responses expose three counters:
- `likesCount` — all active likes;
- `primaryLikesCount` — likes from users whose current `account_role=primary`;
- `shiningLikesCount` — likes from users who are currently both `primary` and `shining`.
Changing `account_role` or `shine` does not alter the raw reaction. The two qualified counters are recalculated from `reactions_state`; therefore old likes automatically enter or leave qualified counters when the actor's current status changes.
+1 -2
View File
@@ -60,8 +60,7 @@
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
| `GetNotifications` | `15_Notifications_API.md` | ответы, связи, события и unread-watermark |
| `SetNotificationState` | `15_Notifications_API.md` | подписанное состояние просмотра уведомлений |
| `GetNotifications` | `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 |
-42
View File
@@ -205,45 +205,3 @@
}
}
```
---
## 5. Profile relation lists (v18)
### `ListUserProfileRelations`
Lightweight paged lists used by clickable counters in a user profile. Supported `listType` values:
- `friends`
- `close_friends`
- `primary_received`
- `primary_given`
- `shine_received`
- `shine_given`
Request:
```json
{
"op": "ListUserProfileRelations",
"requestId": "profile-rel-1",
"payload": { "login": "Alice", "listType": "friends", "limit": 100, "offset": 0 }
}
```
Each item contains a ready-to-render user card: `login`, `firstName`, `lastName`, `avatarAr`, `accountRole`, `shineStatus`, effective `relationType`, `primaryConfirmed`, `shineConfirmed`, plus received primary/shine confirmation counters.
Effective relation priority is `close_friend > friend > contact > none`. Effective `70/80` confirmations are filtered by current voting/status rules; raw blockchain connections are not deleted when temporarily ineffective.
### Voting rules
- new `80` requires the actor to have `account_role=primary`;
- `80` cannot be added to a target with `account_role=non_voting`;
- new `70` requires actor `account_role=primary` and `shine=shining` (legacy `shine=yes` is normalized to `shining`);
- `70` cannot be added to a target with `shine=not_interested`;
- a `non_voting` target may still receive `70` if its shine status allows it;
- old raw `70/80` remain in `connections_state`, but are excluded from effective lists/counts while either side is ineligible. They become effective again automatically if statuses permit later.
### `ListContacts` additions (v18)
Every dialog item now also carries `firstName`, `lastName`, `avatarAr`, `accountRole`, and `shineStatus`. `relationFlag` priority is now `close_friend > friend > contact > none`.
+65 -26
View File
@@ -1,42 +1,81 @@
# API для разработчиков: уведомления
Уведомления являются серверной проекцией событий блокчейна. Сервер возвращает все непросмотренные записи независимо от возраста и просмотренные записи не старше 60 дней. Пагинации нет: выдача содержит все непросмотренные и всю доступную 60-дневную просмотренную историю.
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
## GetNotifications
Текущая операция:
Авторизация обязательна. Обычно payload пустой. Legacy-поле `limit` принимается для совместимости, но в v2 игнорируется. Для обновления badge без загрузки карточек можно передать `{"countsOnly":true}`; тогда массивы лент остаются пустыми, но watermark и `*UnseenCount` возвращаются.
- `GetNotifications`
Ответ содержит три ленты: `replies`, `connections`, `events`, а также `*SeenAtMs` и `*UnseenCount` для каждой категории.
## 1. `GetNotifications`
- `replies`: TEXT_REPLY.
- `connections`: friend/unfriend, close_friend/unclose_friend, shine confirmed/unconfirmed, official confirmed/unconfirmed. Контакты не создают уведомлений.
- `events`: FOLLOW/UNFOLLOW каналов.
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
Фильтр каждой категории: `created_at_ms > seenAtMs OR created_at_ms >= now - 60 days`.
Возвращаются две отдельные ленты:
## SetNotificationState
- `replies` — ответы на сообщения пользователя в каналах и тредах;
- `events` — события добавления в `close_friend`.
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
### Запрос
Запрос:
```json
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
{
"op": "GetNotifications",
"requestId": "notif-001",
"payload": {
"login": "alice",
"limit": 50
}
}
```
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
### Успешный ответ
```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]
```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..."
}
]
}
}
```
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
### Примечание
- `replies` заполняется только для `TEXT_REPLY`.
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
- Другие типы связей в эту ленту не попадают.
@@ -203,22 +203,3 @@ 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,12 +356,3 @@ 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. Формат контейнера при этом не изменяется.
+44 -1285
View File
File diff suppressed because it is too large Load Diff
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="27" stroke="currentColor" stroke-width="4" opacity="0.72"/>
<path d="M32 14l3.2 10.8L46 28l-10.8 3.2L32 42l-3.2-10.8L18 28l10.8-3.2L32 14z" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/>
<path d="M13 51L51 13" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

+8 -7
View File
@@ -202,13 +202,19 @@ self.addEventListener('notificationclick', (event) => {
}
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const existing = allClients[0] || null;
const existing = allClients.find((client) => {
try {
return client.url.includes('/index.html') || client.url.endsWith('/');
} catch {
return false;
}
});
const openUrlBase = './index.html';
const encodedPayload = encodeCallPushPayloadForUrl(payload);
const openUrl = (action === 'accept' || action === 'decline')
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
: openUrlBase;
if (existing) {
try {
@@ -218,11 +224,6 @@ self.addEventListener('notificationclick', (event) => {
action,
payload,
});
} else {
existing.postMessage({
type: 'SHINE_NOTIFICATION_CLICK',
payload,
});
}
} catch {}
await existing.focus();
+3 -5
View File
@@ -12,13 +12,13 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260901134200';
window.__SHINE_BUILD_HASH__ = '20260826145335';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
(function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
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'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -43,9 +43,7 @@ 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" 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 id="toolbar-slot" class="toolbar-slot"></div>
</div>
<div id="modal-root"></div>
<script>
+37 -245
View File
@@ -33,9 +33,6 @@ import {
addAppLogEntry,
authorizeSession,
hydrateMessagesFromStore,
getSavedProfiles,
closeSavedProfile,
switchToSavedProfile,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
@@ -70,7 +67,6 @@ 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';
@@ -95,8 +91,6 @@ import * as messagesList from './pages/messages-list.js?v=202608221218';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202608221218';
import * as userProfileView from './pages/user-profile-view.js';
import * as userProfileListView from './pages/user-profile-list-view.js';
import * as userRelationManageView from './pages/user-relation-manage-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelView from './pages/channel-view.js';
import * as channelAboutView from './pages/channel-about-view.js';
@@ -136,7 +130,6 @@ const routes = {
queue: publicSupportQueueView,
'profile-view': profileView,
'profile-edit-view': profileEditView,
'profiles-view': profilesView,
'wallet-view': walletView,
'settings-view': settingsView,
'access-servers-view': accessServersView,
@@ -161,8 +154,6 @@ const routes = {
'contact-search-view': contactSearchView,
'chat-view': chatView,
user: userProfileView,
'user-profile-list-view': userProfileListView,
'user-relation-manage-view': userRelationManageView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-about-view': channelAboutView,
@@ -218,7 +209,6 @@ 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([
@@ -399,88 +389,14 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
syncDebug();
}
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 = '') {
function clearSlot(slotEl, cssVarName) {
if (!slotEl) return;
slotEl.innerHTML = '';
slotEl.hidden = true;
if (presenceClass) appShellEl?.classList.remove(presenceClass);
setShellMetricVar(cssVarName, 0);
}
function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
function mountSlot(slotEl, cssVarName, node) {
if (!slotEl) return;
slotEl.innerHTML = '';
if (node instanceof Node) {
@@ -489,89 +405,59 @@ function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
} else {
slotEl.hidden = true;
}
if (presenceClass) appShellEl?.classList.toggle(presenceClass, !slotEl.hidden);
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
}
function createChromeController(showAppChrome, initialShellMode = {}) {
function createChromeController(showAppChrome) {
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', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
return;
}
mountSlot(topbarEl, '--topbar-height', topbarNode, 'app-shell--has-topbar');
mountSlot(composerEl, '--composer-height', composerNode, 'app-shell--has-composer');
mountSlot(topbarEl, '--topbar-height', topbarNode);
mountSlot(composerEl, '--composer-height', composerNode);
topbarHeightObserver?.sync?.();
composerHeightObserver?.sync?.();
};
apply();
return {
setTopbar(node = null) {
const nextTopbar = node instanceof Node ? node : null;
if (topbarNode && topbarNode !== nextTopbar) cleanupOwnedNode(topbarNode);
topbarNode = nextTopbar;
topbarNode = node instanceof Node ? node : null;
apply();
},
setComposer(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);
composerNode = node instanceof Node ? node : null;
apply();
},
clear() {
cleanupOwnedNode(topbarNode);
cleanupOwnedNode(composerNode);
topbarNode = null;
composerNode = null;
apply();
},
suspend() {
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
},
resume() {
apply();
},
dispose() {
cleanupOwnedNode(topbarNode);
cleanupOwnedNode(composerNode);
topbarNode = null;
composerNode = null;
disposed = true;
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
resetShellMode();
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
},
};
}
function clearKeepAliveEntries() {
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
currentCleanup = null;
currentChromeCleanup = null;
}
async function unlockHiddenDmAudio() {
@@ -855,77 +741,6 @@ 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();
@@ -1348,15 +1163,14 @@ 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', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
clearSlot(toolbarEl, '--toolbar-height');
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
toolbarEl.innerHTML = '';
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
}
@@ -1379,8 +1193,7 @@ function renderApp() {
return;
}
const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) {
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
navigate('messages-list');
return;
}
@@ -1397,23 +1210,10 @@ 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 {
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
screenEl.innerHTML = '';
const chrome = createChromeController(showAppChrome);
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');
@@ -1427,6 +1227,16 @@ 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);
@@ -1441,9 +1251,9 @@ function refreshToolbarOnly() {
const showAppChrome = page.pageMeta?.showAppChrome !== false
&& !(pageId === 'language-view' && !state.session.isAuthorized);
clearSlot(toolbarEl, '--toolbar-height');
toolbarEl.innerHTML = '';
if (showAppChrome) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
@@ -1472,11 +1282,6 @@ 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: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
@@ -1529,7 +1334,6 @@ async function ensureSessionRuntimeStarted() {
async function init() {
consumeCallPushActionFromUrlIfAny();
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
void tryLockPortraitOrientation();
if (state.session.isLocalDemo) {
@@ -1565,20 +1369,12 @@ async function init() {
const action = String(data.action || '').trim().toLowerCase();
const payload = data.payload || {};
if (action === 'accept' || action === 'decline') {
void (async () => {
const canHandle = await ensurePushTargetProfile(payload, { action });
if (!canHandle) return;
if (!isCallPushTargetForCurrentSession(payload)) return;
savePendingCallPushAction(action, payload);
await processPendingCallPushActionIfPossible();
})();
if (!isCallPushTargetForCurrentSession(payload)) return;
savePendingCallPushAction(action, payload);
void 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 || {};
@@ -1611,8 +1407,7 @@ async function init() {
}
authService.onEvent('SessionRevoked', async () => {
const result = await closeSavedProfile(state.session.login);
window.location.assign(result?.nextProfile ? '/profile' : '/start');
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
authService.onEvent('ForceUiReload', async (evt) => {
@@ -1915,9 +1710,6 @@ async function init() {
void (async () => {
try {
await tryAutoLogin();
if (initialNotificationOpenPayload) {
await handleNotificationClick(initialNotificationOpenPayload);
}
await hydrateMessagesFromStore();
if (!state.session.isLocalDemo) {
startConnectionMonitor();
+73 -148
View File
@@ -1,157 +1,88 @@
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,
placement = 'bottom-end',
align = null,
align = 'right',
leftShift = 0,
transparent = false,
dimBackground = true,
keepAnchorPressed = true,
onOpen = null,
onClose = null,
} = {}) {
let portal = null;
let menuEl = null;
let destroyed = false;
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 } = {}) => {
const close = () => {
if (!portal) return;
portal.remove();
portal = null;
menuEl = null;
if (activeDropdown === api) activeDropdown = null;
setAnchorOpen(false);
anchorEl?.setAttribute('aria-expanded', 'false');
onClose?.();
if (focusAnchor) anchorEl?.focus?.();
};
const position = () => {
if (!portal || !menuEl || !anchorEl) return;
if (!portal || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const margin = 10;
const menuWidth = menuEl.offsetWidth || minWidth;
const side = resolvePlacement({ placement, align });
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
const menuWidth = portal.offsetWidth || minWidth;
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
const desiredLeft = baseLeft - Number(leftShift || 0);
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
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) {
let top = rect.bottom + offset;
const menuHeight = portal.offsetHeight || 180;
if (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);
}
menuEl.style.left = `${Math.round(left)}px`;
menuEl.style.top = `${Math.round(top)}px`;
};
const appendItems = () => {
normalizeItems(items).forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dropdown-menu__divider';
divider.setAttribute('role', 'separator');
menuEl.append(divider);
return;
}
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 = 'dropdown-menu__icon';
icon.innerHTML = item.iconHtml;
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');
button.append(icon);
}
const label = document.createElement('span');
label.textContent = String(item.label || '');
button.append(label);
button.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
close();
await item.action?.();
});
menuEl.append(button);
});
portal.style.left = `${Math.round(left)}px`;
portal.style.top = `${Math.round(top)}px`;
};
const open = () => {
if (destroyed || !anchorEl || portal) return;
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
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`;
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();
items.forEach((item) => {
if (item?.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
menu.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 icon = document.createElement('span');
icon.className = 'shared-dropdown-menu__icon';
icon.innerHTML = item.iconHtml;
btn.append(icon);
} else if (item?.iconSrc) {
const icon = document.createElement('img');
icon.src = item.iconSrc;
icon.alt = '';
icon.setAttribute('aria-hidden', 'true');
btn.append(icon);
}
const label = document.createElement('span');
label.textContent = String(item?.label || '');
btn.append(label);
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
close();
item?.action?.();
});
portal.append(backdrop);
}
menu.append(btn);
});
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);
menu.addEventListener('click', (event) => event.stopPropagation());
document.body.append(menu);
portal = menu;
anchorEl.setAttribute('aria-expanded', 'true');
onOpen?.();
position();
};
@@ -166,49 +97,43 @@ export function createDropdownMenu({
event.stopPropagation();
toggle();
};
const onOutsidePointerDown = (event) => {
const onOutsideClick = (event) => {
if (!portal) return;
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
close();
};
const onPeerDropdownOpen = (event) => {
if (!portal || event?.detail?.owner === anchorEl) return;
close();
};
const onKeydown = (event) => {
if (event.key !== 'Escape' || !portal) return;
close({ focusAnchor: true });
close();
anchorEl?.focus();
};
const onNavigation = () => close();
const onViewportChange = () => position();
setAnchorOpen(false);
anchorEl?.setAttribute('aria-haspopup', 'menu');
anchorEl?.setAttribute('aria-expanded', 'false');
anchorEl?.addEventListener('click', onAnchorClick);
document.addEventListener('pointerdown', onOutsidePointerDown, true);
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onKeydown);
window.addEventListener('popstate', onNavigation);
window.addEventListener('hashchange', onNavigation);
window.addEventListener('resize', onViewportChange, { passive: true });
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', position, { passive: true });
window.addEventListener('scroll', position, { passive: true, capture: true });
const api = {
return {
open,
close,
toggle,
position,
get isOpen() {
return Boolean(portal);
},
destroy() {
if (destroyed) return;
destroyed = true;
close();
anchorEl?.removeEventListener('click', onAnchorClick);
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onKeydown);
window.removeEventListener('popstate', onNavigation);
window.removeEventListener('hashchange', onNavigation);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
setAnchorOpen(false);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', position);
window.removeEventListener('scroll', position, true);
},
};
return api;
}
+58
View File
@@ -0,0 +1,58 @@
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 -25
View File
@@ -1,5 +1,5 @@
import { resolveToolbarActive } from '../router.js';
import { state, authService } from '../state.js';
import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
@@ -72,8 +72,6 @@ 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 = `
@@ -99,14 +97,6 @@ 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 {
@@ -115,19 +105,5 @@ 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;
}
-112
View File
@@ -1,112 +0,0 @@
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;
}
+7 -7
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -427,7 +427,7 @@ export function render({navigate, chrome}) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = items.map((item) => (
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
`<button type="button" class="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, chrome}) {
}
});
chrome?.setTopbar(createTopBar({
title: 'Сервер доступа',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
renderHeader({
title: 'Сервер доступа',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
introCard,
listCard,
addCard,
+8 -6
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -78,14 +78,16 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24);
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Создание канала',
back: { label: '<', onClick: () => navigate('channels-list') },
}));
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
const form = document.createElement('form');
form.className = 'card stack';
@@ -95,7 +97,7 @@ export function render({navigate, chrome}) {
<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="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар"></button>
<button type="button" class="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 { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
@@ -47,14 +47,16 @@ function createDebounced(fn, delayMs = 240) {
};
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Новый персональный публичный чат',
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}));
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}),
);
const form = document.createElement('form');
form.className = 'card stack';
@@ -117,7 +119,7 @@ export function render({navigate, chrome}) {
rows.slice(0, 8).forEach((login) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-button channel-search-item';
btn.className = 'channel-search-item';
btn.textContent = String(login);
btn.addEventListener('click', () => {
selectedCanonicalLogin = String(login);
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
@@ -11,14 +11,16 @@ function formatTime(ts) {
}
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Лог приложения',
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
const controls = document.createElement('div');
controls.className = 'card row';
+57 -45
View File
@@ -7,12 +7,16 @@ 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'}`;
@@ -60,10 +64,25 @@ function renderTile(item, index) {
return tile;
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
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';
@@ -72,6 +91,7 @@ export function render({navigate, chrome}) {
const uploadFile = async () => {
statusLine.textContent = '';
closeUploadsMenu(controls);
try {
await openArweaveAttachmentManager({
login: state.session.login,
@@ -114,50 +134,42 @@ export function render({navigate, chrome}) {
}, 0);
}
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"]')?.addEventListener('click', () => {
void uploadFile();
});
chrome?.setTopbar(topbar);
screen.append(statusLine, list);
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);
renderHistory();
return screen;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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 = createTopBar({
const topbar = renderHeader({
title: 'О канале',
back: {
leftAction: {
label: '←',
onClick: () => {
if (window.history.length > 1) {
+83 -134
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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: 'Тред', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
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, { isActive = () => true } = {}) {
function openBlockchainDetailsModal(details) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -471,7 +471,6 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
});
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
@@ -496,7 +495,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-button draft-attachment-chip';
button.className = 'draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -509,7 +508,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -570,11 +569,9 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
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);
}
@@ -592,11 +589,10 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -606,7 +602,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
if (textEl) textEl.focus();
}
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
function openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -672,10 +668,8 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
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, 'Не удалось сделать репост.');
}
@@ -722,7 +716,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -755,20 +749,16 @@ 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, 'Не удалось удалить сообщение.');
}
});
@@ -785,8 +775,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
const likes = Number(node?.likesCount || 0);
const primaryLikes = Number(node?.primaryLikesCount || 0);
const shiningLikes = Number(node?.shiningLikesCount || 0);
const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
@@ -808,7 +796,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'ui-button channel-message-author-tile';
authorTile.className = 'channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
@@ -831,7 +819,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.className = 'message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -854,7 +842,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.className = 'channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -875,7 +863,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 = 'ui-button deleted-message-pill';
deleted.className = 'deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${author}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -925,12 +913,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'ui-button channel-action-item thread-like-btn';
likeButton.className = '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>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
<span class="channel-action-counter">${likes}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
@@ -959,7 +947,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
replyButton.className = 'channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -972,15 +960,30 @@ 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.
// Backend/subtype/counters remain supported so the feature can be restored later.
const ratingButton = document.createElement('button');
ratingButton.type = 'button';
ratingButton.className = 'channel-action-item thread-rating-btn';
ratingButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оценка</span>
<span class="channel-action-counter">${ratings}</span>
`;
setActionTitle(ratingButton, 'Оценка');
ratingButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate: handlers.navigate,
mode: 'rating',
onSubmit: async (textValue) => handlers.onRating(target, textValue),
});
});
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item thread-share-btn';
shareButton.className = 'channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
@@ -994,11 +997,11 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton);
actions.append(likeButton, replyButton, ratingButton, shareButton);
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
originalButton.className = 'ui-button channel-action-item';
originalButton.className = 'channel-action-item';
originalButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span>
@@ -1020,7 +1023,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.className = 'channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -1036,13 +1039,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 = 'ui-button channel-action-item';
editButton.className = 'channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -1097,12 +1100,11 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
return wrap;
}
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
function applyPendingScroll(screen, routeKey) {
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];
@@ -1120,7 +1122,7 @@ function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
}
};
return window.setTimeout(doScroll, 20);
setTimeout(doScroll, 20);
}
function renderSkeleton(screen) {
@@ -1132,18 +1134,15 @@ function renderSkeleton(screen) {
}
export function render({ navigate, route, chrome }) {
let selector = parseThreadSelector(route);
const 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';
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
const refreshTimers = new Set();
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
@@ -1151,10 +1150,10 @@ export function render({ navigate, route, chrome }) {
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
const header = renderHeader({
centerNode: threadHeaderButton,
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
{
label: '↑',
title: 'К списку каналов',
@@ -1171,12 +1170,19 @@ export function render({ navigate, route, chrome }) {
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
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 showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
@@ -1199,7 +1205,6 @@ export function render({ navigate, route, chrome }) {
const handlers = {
navigate,
isActive: () => !disposed,
onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
@@ -1217,14 +1222,12 @@ export function render({ navigate, route, chrome }) {
await authService.addBlockLike({ login, storagePwd, message: target });
}
if (disposed) return;
setMessageReactionState(target, nextReaction);
softHaptic(10);
void refresh();
rerender();
} catch (error) {
if (disposed) return;
setMessageReactionState(target, previousReaction || 'unliked');
void refresh();
rerender();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -1233,27 +1236,24 @@ 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('');
void refresh();
rerender();
},
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('');
void refresh();
rerender();
},
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 = {
@@ -1278,7 +1278,6 @@ export function render({ navigate, route, chrome }) {
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
@@ -1287,7 +1286,6 @@ export function render({ navigate, route, chrome }) {
message: target,
text,
});
ensureActive();
softHaptic(12);
showToast('Репост опубликован');
showStatus('');
@@ -1303,7 +1301,6 @@ 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);
@@ -1337,69 +1334,33 @@ export function render({ navigate, route, chrome }) {
isChannelPost: meta?.isChannelPost === true,
channel: selector?.channel || null,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
showStatus('');
void refresh();
rerender();
},
};
screen.append(statusBox);
const clearContent = () => {
for (const timerId of refreshTimers) window.clearTimeout(timerId);
refreshTimers.clear();
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
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 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);
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 : []),
@@ -1422,7 +1383,6 @@ 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) => (
@@ -1437,7 +1397,6 @@ 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
@@ -1467,7 +1426,6 @@ 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 : [];
@@ -1493,7 +1451,6 @@ 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() || 'неизвестно';
@@ -1510,10 +1467,10 @@ export function render({ navigate, route, chrome }) {
};
}
let localSeq = 0;
let seq = 0;
const nextNumber = () => {
localSeq += 1;
return localSeq;
seq += 1;
return seq;
};
let ancestorsWrap = null;
@@ -1563,33 +1520,25 @@ export function render({ navigate, route, chrome }) {
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
applyPendingScroll(screen, routeKey);
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
trackTimer(window.setTimeout(() => {
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
setTimeout(() => {
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.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
appScreen?.classList.remove('channels-scroll-clean');
};
void refresh();
return screen;
}
+99 -227
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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: 'Канал', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -393,11 +393,22 @@ function createChannelReadTracker({
}
window.addEventListener('resize', onResize);
if (canWrite) {
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
if (initialSyncRequired) {
void authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: safeMessagesCount,
storagePwd,
}).catch(() => {});
persistedSeenCount = safeMessagesCount;
desiredSeenCount = safeMessagesCount;
void flush();
} else {
window.setTimeout(() => measure(), 120);
}
window.setTimeout(() => measure(), 120);
const cleanup = () => {
disposed = true;
@@ -775,7 +786,7 @@ function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, ra
};
}
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
function openBlockchainDetailsModal(details) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -809,7 +820,6 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
});
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
@@ -825,7 +835,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-button draft-attachment-chip';
button.className = 'draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -838,7 +848,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -899,11 +909,9 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
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);
}
@@ -921,11 +929,10 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -935,7 +942,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => t
if (textEl) textEl.focus();
}
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
@@ -977,10 +984,8 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive =
errorEl.textContent = '';
try {
await onSubmit(String(textEl?.value || '').trim());
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
}
@@ -990,7 +995,7 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive =
if (textEl) textEl.focus();
}
function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActive = () => true }) {
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
const root = document.getElementById('modal-root');
const rows = (Array.isArray(options) ? options : [])
.map((item, index) => `
@@ -1023,7 +1028,6 @@ function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActi
const option = options[idx];
if (!option) return;
close();
if (!isActive()) return;
await onSelect(option);
});
});
@@ -1049,12 +1053,8 @@ function flashAndScrollToMessage(messageRef) {
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
if (target.isConnected) target.classList.add('is-focus-flash');
}, 60);
window.setTimeout(() => {
if (target.isConnected) target.classList.remove('is-focus-flash');
}, 1800);
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
window.setTimeout(() => 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 = 'ui-button entrypoint-history-item';
item.className = '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, isActive = () => true }) {
function openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -1191,10 +1191,8 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
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, 'Не удалось сделать репост.');
}
@@ -1203,7 +1201,7 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
function openAddMessageModal({ channelName, onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
@@ -1271,11 +1269,9 @@ function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () =>
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, 'Не удалось отправить сообщение.');
}
@@ -1293,11 +1289,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () =>
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!isActive() || !item) return;
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -1343,7 +1338,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
@@ -1377,20 +1372,16 @@ 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, 'Не удалось удалить сообщение.');
}
});
@@ -1426,8 +1417,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0),
primaryLikesCount: Number(message?.primaryLikesCount || 0),
shiningLikesCount: Number(message?.shiningLikesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message),
@@ -1746,7 +1735,7 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
}
};
return window.setTimeout(doScroll, 20);
setTimeout(doScroll, 20);
}
function mapChannelMetaEvent(event, fallbackChannel) {
@@ -1802,7 +1791,6 @@ function renderPostCard(post, {
onRepost,
onShare,
onEdit,
isActive = () => true,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
@@ -1813,7 +1801,7 @@ function renderPostCard(post, {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'ui-button channel-message-author-tile';
authorTile.className = 'channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin);
@@ -1842,7 +1830,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.className = 'message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -1862,7 +1850,7 @@ function renderPostCard(post, {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.className = 'channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -1896,7 +1884,7 @@ function renderPostCard(post, {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'ui-button deleted-message-pill';
deleted.className = 'deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -1967,13 +1955,13 @@ function renderPostCard(post, {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'ui-button channel-action-item channel-action-like';
likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
@@ -1993,7 +1981,7 @@ function renderPostCard(post, {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'ui-button channel-action-item channel-action-reply';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -2006,17 +1994,33 @@ function renderPostCard(post, {
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, replyButton);
const ratingButton = document.createElement('button');
ratingButton.type = 'button';
ratingButton.className = 'channel-action-item channel-action-rating';
ratingButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оценка</span>
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
`;
setActionTitle(ratingButton, 'Оценка');
ratingButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
mode: 'rating',
onSubmit: async (text) => onRating(post.messageRef, text),
});
});
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, ratingButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
@@ -2033,7 +2037,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 = 'ui-button channel-action-item';
originalBtn.className = 'channel-action-item';
originalBtn.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Оригинал</span>
@@ -2055,7 +2059,7 @@ function renderPostCard(post, {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.className = 'channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -2071,13 +2075,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 = 'ui-button channel-action-item';
editButton.className = 'channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -2177,7 +2181,6 @@ 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) {
@@ -2215,10 +2218,10 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
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;
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 tracker = createChannelReadTracker({
screen,
@@ -2232,11 +2235,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
initialSeenCount: readCount,
});
return () => {
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
if (unreadScrollTimer) window.clearTimeout(unreadScrollTimer);
tracker.cleanup();
};
return tracker.cleanup;
}
function renderSkeleton(screen) {
@@ -2254,22 +2253,14 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
let cleanupSeenTracking = null;
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
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 = '';
@@ -2279,79 +2270,33 @@ 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 = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
actions: [
const header = renderHeader({
centerNode: channelHeaderButton,
leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [
{ 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('.topbar__right .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.topbar__right .channel-header-more-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-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 = () => {
@@ -2383,14 +2328,12 @@ export function render({ navigate, route, chrome }) {
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
if (disposed) return;
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
void refresh();
rerender();
} catch (error) {
if (disposed) return;
setMessageReactionState(messageRef, previousReaction || 'unliked');
void refresh();
rerender();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -2400,27 +2343,25 @@ 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('Ответ отправлен');
void refresh();
rerender();
};
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('Оценка отправлена');
void refresh();
rerender();
};
const onStatusAction = async (post) => {
@@ -2430,12 +2371,10 @@ 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({
@@ -2445,10 +2384,9 @@ export function render({ navigate, route, chrome }) {
text,
statusSubType: option.subType,
});
ensureActive();
softHaptic(14);
showToast(`${option.label} сохранено`);
void refresh();
rerender();
},
});
},
@@ -2488,12 +2426,10 @@ 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,
@@ -2502,10 +2438,9 @@ export function render({ navigate, route, chrome }) {
message: messageRef,
text,
});
ensureActive();
if (isSameChannelSelector(channel, activeSelector)) {
pendingScrollByRoute.set(routeKey, '__LAST__');
void refresh();
rerender();
}
softHaptic(12);
showToast('Репост опубликован');
@@ -2522,7 +2457,6 @@ 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);
@@ -2544,12 +2478,11 @@ export function render({ navigate, route, chrome }) {
text: bodyText,
msgSubType,
});
ensureActive();
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
void refresh();
rerender();
};
const onEditPost = async (messageRef, text) => {
@@ -2566,10 +2499,9 @@ export function render({ navigate, route, chrome }) {
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
void refresh();
rerender();
};
const onEditChannelMeta = async ({ title, description, avatar }) => {
@@ -2585,65 +2517,21 @@ export function render({ navigate, route, chrome }) {
description,
avatar,
});
ensureActive();
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
softHaptic(12);
showToast('Профиль канала обновлён');
void refresh();
rerender();
};
screen.append(statusBox);
const clearContent = () => {
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null;
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;
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);
let cleanupSeenTracking = null;
(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);
@@ -2671,10 +2559,6 @@ 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;
@@ -2688,12 +2572,10 @@ 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);
@@ -2783,41 +2665,31 @@ 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('Подписка на канал выполнена');
void refresh();
rerender();
} 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, 'Не удалось загрузить канал.'), () => {
void refresh();
});
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
}
};
})();
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
void refresh();
return screen;
}
+304 -95
View File
@@ -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: 'Каналы', shellMode: { scrollbar: 'hidden' } };
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
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';
@@ -52,18 +52,6 @@ function cleanChannelMessagePreview(text) {
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
}
// Keep the channel-list preview tolerant to small API naming changes. The current
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
// so the third line (last message + time) does not silently disappear.
function resolveChannelLastMessage(summary) {
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
return {
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
};
}
function isChannelsDemoMode() {
try {
const qs = new URLSearchParams(window.location.search);
@@ -111,58 +99,6 @@ 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();
@@ -310,7 +246,7 @@ function renderSuggestions(container, values, onPick) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-button channel-search-item';
btn.className = 'channel-search-item';
btn.textContent = value;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -580,7 +516,7 @@ function openChannelFinderModal({ navigate }) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-button channel-search-item';
btn.className = 'channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -783,7 +719,6 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const isOwn = bucketKey === 'own';
const title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`;
const lastMessage = resolveChannelLastMessage(summary);
return {
id: rowId,
@@ -802,10 +737,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(lastMessage.createdAtMs || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
isOwnChannel: isOwn,
isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true,
@@ -997,6 +932,255 @@ 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;
listState.topMenuAnchor?.setAttribute('aria-expanded', 'false');
listState.topMenuAnchor?.classList.remove('menu-open-pressed');
listState.topMenuAnchor = 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';
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);
anchorEl.setAttribute('aria-expanded', 'true');
anchorEl.classList.add('menu-open-pressed');
listState.topMenuAnchor = anchorEl;
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';
@@ -1103,6 +1287,7 @@ function updateBottomCta({ button }) {
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
@@ -1128,7 +1313,6 @@ 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');
@@ -1163,29 +1347,40 @@ 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,
topMenuAnchor: 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,
placement: 'bottom-start',
align: 'left',
leftShift: 72,
minWidth: 220,
items: [
@@ -1195,25 +1390,33 @@ export function render({ navigate, route, chrome }) {
],
});
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') },
],
},
},
],
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.setAttribute('aria-haspopup', 'menu');
topMenuBtn.setAttribute('aria-expanded', 'false');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
if (listState.topMenuAnchor === topMenuBtn) {
closeTopChannelsMenu(listState);
return;
}
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
});
});
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@@ -1222,7 +1425,9 @@ export function render({ navigate, route, chrome }) {
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
renderListContent({
screen,
container: contentEl,
@@ -1233,6 +1438,7 @@ 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 });
};
@@ -1252,7 +1458,10 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
channelsFilterMenu.destroy();
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
+269 -219
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { directMessages } from '../mock-data.js';
@@ -33,18 +32,7 @@ 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: 'Чат',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'composer',
contentUnderTopbar: true,
contentUnderBottom: true,
scrollContainer: 'nested',
},
};
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function menuIconSvg(name) {
@@ -61,108 +49,115 @@ 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 normalizeChatRelationType(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none';
}
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>
`;
function chatRelationLabel(value) {
switch (normalizeChatRelationType(value)) {
case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг';
case 'contact': return 'Контакт';
default: return 'Не в контактах';
}
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 createChatHeaderParts(login, navigate) {
const cleanLogin = String(login || '').trim() || 'unknown';
let currentPeer = {
login: cleanLogin,
firstName: '',
lastName: '',
avatar: null,
relationType: 'none',
};
const identityButton = document.createElement('button');
identityButton.type = 'button';
identityButton.className = 'chat-header-peer-btn';
identityButton.title = `Меню ${cleanLogin}`;
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot';
const textWrap = document.createElement('span');
textWrap.className = 'chat-header-peer-text';
const nameEl = document.createElement('span');
nameEl.className = 'chat-header-peer-name';
const metaEl = document.createElement('span');
metaEl.className = 'chat-header-peer-meta';
textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap);
const renderPeer = () => {
const firstName = String(currentPeer?.firstName || '').trim();
const lastName = String(currentPeer?.lastName || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
const avatar = renderUserAvatar({
login: cleanLogin,
firstName,
lastName,
avatar: currentPeer?.avatar?.ar
? {
ar: String(currentPeer.avatar.ar || '').trim(),
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(avatar);
};
const updatePeer = (peer) => {
if (!peer || typeof peer !== 'object') return;
currentPeer = {
...currentPeer,
...peer,
login: String(peer.login || cleanLogin).trim() || cleanLogin,
relationType: normalizeChatRelationType(peer.relationType),
};
renderPeer();
};
renderPeer();
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)),
},
],
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
login: cleanLogin,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
return {
center: identityButton,
updatePeer,
getPeer: () => ({ ...currentPeer }),
cleanup: () => identityMenu.destroy(),
const avatarButton = document.createElement('button');
avatarButton.type = 'button';
avatarButton.className = 'chat-header-avatar-btn';
avatarButton.title = `Меню ${cleanLogin}`;
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
avatarButton.append(avatarSlot);
const loginEl = document.createElement('button');
loginEl.type = 'button';
loginEl.className = 'chat-header-login chat-header-login-btn';
loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.textContent = cleanLogin;
void loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
if (!avatarSlot.isConnected) return;
const upgradedAvatar = renderUserAvatar({
login: cleanLogin,
firstName: String(snapshot?.firstName || '').trim(),
lastName: String(snapshot?.lastName || '').trim(),
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
})
.catch(() => {});
const openMenu = (event) => {
if (!cleanLogin || cleanLogin === 'unknown') return;
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
};
avatarButton.addEventListener('click', openMenu);
loginEl.addEventListener('click', openMenu);
return { centerNode: loginEl, avatarButton };
}
function truncatePreviewText(value, maxLen = 72) {
@@ -239,30 +234,18 @@ function openChatConfirmModal({
});
}
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
function openDeleteChatConfirmModal({ contactName = '', 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>
${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>
`}
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</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>
@@ -277,12 +260,10 @@ function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', o
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = isProtectedRelation
? true
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
await onConfirm({ deleteHistory });
}
});
}
@@ -389,6 +370,84 @@ function openMessageActionsMenu({
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onVideoCall,
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>
<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-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;
@@ -743,7 +802,7 @@ function renderLog(
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'ui-button bubble-reply-preview';
replyBox.className = 'bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
@@ -888,6 +947,7 @@ 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) || {
@@ -900,7 +960,6 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack dm-screen dm-chat-screen';
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
let peerRelationType = isKnownContact ? 'contact' : 'none';
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true;
@@ -995,24 +1054,6 @@ export function render({ navigate, route, chrome }) {
notifyUnreadStateUpdated();
};
const addPeerToContacts = async () => {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
peerRelationType = 'contact';
chatHeaderParts?.updatePeer?.({ relationType: 'contact' });
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []);
notifyUnreadStateUpdated();
showToast('Добавлено в контакты', { timeoutMs: 1200 });
};
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div');
@@ -1029,10 +1070,10 @@ export function render({ navigate, route, chrome }) {
log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = createTopBar({
center: chatHeaderParts.center,
back: { label: '←', onClick: () => navigate('messages-list') },
actions: [
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
title: 'Позвонить',
ariaLabel: 'Позвонить',
@@ -1045,28 +1086,14 @@ export function render({ navigate, route, chrome }) {
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
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 });
}
},
}
: null,
{
label: 'Очистить историю',
iconHtml: menuIconSvg('clear'),
action: () => openChatConfirmModal({
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
@@ -1079,56 +1106,81 @@ export function render({ navigate, route, chrome }) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
}),
});
},
{
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 });
onDeleteChat: async () => {
openDeleteChatConfirmModal({
contactName: contact.name,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
}
},
});
},
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);
const chatHeaderLeft = chatHeader.querySelector('.header-left');
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
chrome?.setTopbar(chatHeader);
if (!isKnownContact) {
const card = document.createElement('div');
card.className = 'card';
const btn = document.createElement('button');
btn.className = 'secondary-btn';
btn.type = 'button';
btn.textContent = 'Добавить собеседника в контакты';
btn.addEventListener('click', async () => {
try {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
addAppLogEntry({
level: 'info',
source: 'contacts',
message: `Пользователь ${chatId} добавлен в контакты`,
});
card.remove();
} catch (e) {
addAppLogEntry({
level: 'warn',
source: 'contacts',
message: 'Не удалось добавить пользователя в контакты',
details: { login: chatId, error: e?.message || 'unknown' },
});
}
});
card.append(btn);
screen.append(card);
}
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
@@ -1520,10 +1572,6 @@ export function render({ navigate, route, chrome }) {
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
});
if (payload?.peer) {
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
chatHeaderParts.updatePeer(payload.peer);
}
await mergeDirectMessagesPage(chatId, payload?.messages || []);
historyHasMore = Boolean(payload?.hasMore);
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
@@ -1682,6 +1730,8 @@ 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;
}
+8 -11
View File
@@ -1,17 +1,19 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Подключить устройство',
back: { label: '←', onClick: () => navigate('device-view') },
}));
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
@@ -146,11 +148,6 @@ export function render({navigate, chrome}) {
}
});
screen.append(card);
const modalRoot = document.getElementById('modal-root');
modalRoot?.append(helpModal);
screen.cleanup = () => {
helpModal.remove();
};
screen.append(card, helpModal);
return screen;
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-search-screen';
let searchTimer = 0;
@@ -172,11 +172,11 @@ export function render({navigate, chrome}) {
resultsCard.append(status, resultsList);
chrome?.setTopbar(createTopBar({
title: 'Поиск контактов',
back: { label: '←', onClick: () => navigate('messages-list') },
}));
screen.append(
renderHeader({
title: 'Поиск контактов',
leftAction: { label: '←', onClick: () => navigate('messages-list') },
}),
formCard,
resultsCard,
);
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, state } from '../state.js';
import {
isClientErrorReportingEnabled,
@@ -246,14 +246,16 @@ function openUiErrorReportingModal() {
});
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Настройки разработчика',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
+7 -5
View File
@@ -1,15 +1,17 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Подключить через камеру',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const frame = document.createElement('div');
frame.className = 'camera-shell';
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
@@ -165,7 +165,7 @@ function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
} catch {}
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let savedKeys = null;
@@ -178,10 +178,12 @@ export function render({navigate, chrome}) {
let dialogMode = '';
let pendingTransferRequest = null;
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Подключить по коду',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const transferDialog = document.createElement('div');
transferDialog.className = 'pairing-transfer-dialog';
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import {
@@ -9,14 +9,16 @@ import {
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Показать QR-код',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack qr-card';
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authService,
isSessionInvalidError,
@@ -31,17 +31,19 @@ function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server';
}
export function render({navigate, route, chrome}) {
export function render({ navigate, route }) {
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];
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Сеанс устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
if (!session) {
const empty = document.createElement('div');
+8 -6
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
isSessionInvalidError,
refreshSessions,
@@ -40,14 +40,16 @@ function sortSessionsByOnline(sessions = []) {
});
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Устройства',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const actions = document.createElement('div');
actions.className = 'card stack';
@@ -73,7 +75,7 @@ export function render({navigate, chrome}) {
const createSessionItem = (session, isCurrent) => {
const item = document.createElement('button');
item.className = 'ui-button session-item';
item.className = 'session-item';
item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType);
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
+2 -2
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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(
createTopBar({
renderHeader({
title: 'DEVNET пополнение',
}),
senderBox,
+3 -3
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authorizeLocalDemoSession,
isLocalDemoAvailable,
@@ -173,9 +173,9 @@ export function render({ navigate }) {
actions.append(serverUiButton, cancelButton, saveButton);
screen.append(
createTopBar({
renderHeader({
title: 'Настройки входа',
back: { label: '←', onClick: () => navigate('start-view') },
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
body,
actions,
+3 -3
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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(
createTopBar({
renderHeader({
title: 'Какие ключи сохранить',
back: { label: '←', onClick: () => navigate('login-password-view') },
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { saveEntryLanguage, state } from '../state.js';
export const pageMeta = { id: 'language-view', title: 'Язык' };
@@ -8,19 +8,21 @@ function resolveReturnPage() {
return stored === 'start-view' ? 'start-view' : 'settings-view';
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack language-screen';
const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Язык / Language',
back: { label: '←', onClick: () => {
leftAction: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
} },
}));
}),
);
const card = document.createElement('div');
card.className = 'card stack language-choice-card';
+8 -13
View File
@@ -1,9 +1,7 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
consumeAuthReturnPage,
isAddingProfileLogin,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
@@ -207,13 +205,10 @@ export function render({ navigate }) {
try {
await authService.reconnect(state.entrySettings.shineServer);
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
const addingProfile = isAddingProfileLogin();
if (!addingProfile) {
await terminateCurrentSession({ closeServerSession: true });
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
}
await clearStoredMessages(session.login).catch(() => {});
await terminateCurrentSession({ closeServerSession: true });
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().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);
@@ -226,7 +221,7 @@ export function render({ navigate }) {
state.loginDraft.password = '';
await refreshSessions();
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
navigate(consumeAuthReturnPage('profile-view'));
navigate('profile-view');
} catch (error) {
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
setAuthError(message);
@@ -244,9 +239,9 @@ export function render({ navigate }) {
});
screen.append(
createTopBar({
renderHeader({
title: 'Войти по QR-коду',
back: {
leftAction: {
label: '←',
onClick: () => {
stopCamera();
+10 -18
View File
@@ -1,8 +1,6 @@
import {
authService,
authorizeSession,
consumeAuthReturnPage,
isAddingProfileLogin,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
@@ -184,13 +182,10 @@ export function render({ navigate }) {
const finalizeAuthorizedLogin = async (keys, login) => {
const session = await authService.createSessionFromImportedSecrets(login, keys);
const addingProfile = isAddingProfileLogin();
if (!addingProfile) {
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
}
await clearStoredMessages(session.login).catch(() => {});
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
const resumed = await authService.resumeSession(session.login, session.sessionId);
@@ -204,7 +199,7 @@ export function render({ navigate }) {
await refreshSessions();
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
showToast(`Устройство подключено для @${resumed.login || session.login}`);
navigate(consumeAuthReturnPage('profile-view'));
navigate('profile-view');
};
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
@@ -220,13 +215,10 @@ export function render({ navigate }) {
sessionType: Number(payloadSession?.sessionType || 50) || 50,
};
const addingProfile = isAddingProfileLogin();
if (!addingProfile) {
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
}
await clearStoredMessages(login).catch(() => {});
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await authService.persistSessionMaterial(login, sessionMaterial);
const resumed = await authService.resumeSession(login, sessionId);
authorizeSession({
@@ -239,7 +231,7 @@ export function render({ navigate }) {
await refreshSessions();
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
showToast(`Wallet-session подключена для @${resumed.login || login}`);
navigate(consumeAuthReturnPage('profile-view'));
navigate('profile-view');
};
const schedulePoll = () => {
+3 -3
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authService,
clearAuthMessages,
@@ -170,9 +170,9 @@ export function render({ navigate }) {
panel.append(title, passwordField, status, actions);
screen.append(
createTopBar({
renderHeader({
title: '',
back: { label: '←', onClick: () => navigate('login-view') },
leftAction: { label: '←', onClick: () => navigate('login-view') },
}),
panel,
overlay,
+3 -14
View File
@@ -1,7 +1,6 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import {
authService,
cancelAddProfileLogin,
clearAuthMessages,
setAuthBusy,
setAuthError,
@@ -154,19 +153,9 @@ export function render({ navigate }) {
panel.append(title, loginField, status, remoteWrap, actions);
screen.append(
createTopBar({
renderHeader({
title: '',
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
await cancelAddProfileLogin();
navigate('profiles-view');
return;
}
navigate('start-view');
},
},
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
panel,
);
+126 -71
View File
@@ -10,7 +10,6 @@ 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';
@@ -27,12 +26,12 @@ const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map();
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
const RELATION_ORDER = new Map([
['close_friend', 0],
['friend', 1],
['contact', 2],
['none', 3],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) {
@@ -56,19 +55,16 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) {
function createDmAvatar(login, { className = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
firstName: String(firstName || '').trim(),
lastName: String(lastName || '').trim(),
avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null,
size: 'lg',
title,
className,
});
if (!cleanLogin || avatar?.ar) return avatarEl;
if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
@@ -91,7 +87,7 @@ function createDmAvatar(login, { className = '', avatar = null, firstName = '',
function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
if (clean === 'close_friend' || clean === 'contact') return clean;
return 'none';
}
@@ -103,8 +99,6 @@ function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'friend':
return 'друг';
case 'contact':
return 'контакт';
default:
@@ -201,67 +195,140 @@ 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 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);
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());
let currentChatFilter = 'all';
const filterTitle = document.createElement('button');
filterTitle.type = 'button';
filterTitle.className = 'dm-head-filter-title';
filterTitle.textContent = 'Чаты';
const filterTitle = head.querySelector('#dm-chat-filter-title');
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
friend: 'Друзья',
contact: 'Контакты',
none: 'Новые',
};
let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: filterTitle,
placement: 'bottom-start',
align: 'left',
leftShift: 72,
minWidth: 225,
items: [
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
],
});
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 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 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';
@@ -272,11 +339,7 @@ function renderRow(item) {
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar,
firstName: item.firstName,
lastName: item.lastName,
});
const avatarEl = createDmAvatar(item.peerLogin);
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
@@ -300,10 +363,7 @@ function renderRow(item) {
const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time');
if (titleEl) {
const fullName = [String(item.firstName || '').trim(), String(item.lastName || '').trim()].filter(Boolean).join(' ');
titleEl.textContent = fullName || String(item.peerLogin || '');
}
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap);
@@ -327,10 +387,7 @@ function renderRow(item) {
try {
const payload = await authService.listContacts();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = dialogs
.filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none')
.map((dialog) => String(dialog?.peerLogin || '').trim())
.filter(Boolean);
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
setContacts(contacts);
list.innerHTML = '';
@@ -344,11 +401,6 @@ function renderRow(item) {
id: peerLogin,
peerLogin,
relationFlag,
firstName: String(dialog?.firstName || '').trim(),
lastName: String(dialog?.lastName || '').trim(),
avatar: dialog?.avatar && typeof dialog.avatar === 'object' ? dialog.avatar : null,
accountRole: String(dialog?.accountRole || '').trim(),
shineStatus: String(dialog?.shineStatus || '').trim(),
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unreadCount: Number(dialog?.unreadCount || 0),
@@ -374,9 +426,6 @@ 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);
@@ -448,7 +497,13 @@ 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;
+157 -59
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
@@ -6,20 +6,8 @@ import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { createForceGraph } from './network/force-graph.js';
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: 'Связи',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'toolbar',
fadeProfile: 'edge',
contentUnderTopbar: true,
scrollContainer: 'locked',
},
};
export const pageMeta = { id: 'network-view', title: 'Связи' };
const GENDER_MALE = 'male';
const GENDER_FEMALE = 'female';
@@ -86,12 +74,6 @@ function getMarkByLogin(allUsers) {
if (!login) return;
map.set(normKey(login), {
login,
firstName: String(row?.firstName || '').trim(),
lastName: String(row?.lastName || '').trim(),
displayName: userDisplayName({ login, firstName: row?.firstName, lastName: row?.lastName }),
relationType: String(row?.relationType || '').trim().toLowerCase(),
primaryConfirmed: Boolean(row?.primaryConfirmed),
shineConfirmed: Boolean(row?.shineConfirmed),
official: Boolean(row?.official),
shine: Boolean(row?.shine),
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
@@ -123,8 +105,10 @@ function applyRelativeGender(map, rows) {
function getRelativeGenderMap(graph) {
const map = new Map();
// Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды.
void graph;
applyRelativeGender(map, graph?.parents);
applyRelativeGender(map, graph?.children);
applyRelativeGender(map, graph?.siblings);
applyRelativeGender(map, graph?.spouses);
return map;
}
@@ -132,8 +116,6 @@ function buildGraphModel(graph, centerLogin) {
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
const outFriends = toSet(graph?.outFriends);
const inFriends = toSet(graph?.inFriends);
const outCloseFriends = toSet(graph?.outCloseFriends);
const inCloseFriends = toSet(graph?.inCloseFriends);
const outParents = toSet(graph?.outParents);
const inParents = toSet(graph?.inParents);
const outChildren = toSet(graph?.outChildren);
@@ -147,8 +129,8 @@ function buildGraphModel(graph, centerLogin) {
const inContacts = toSet(graph?.inContacts);
const outFollows = toSet(graph?.outFollows);
const inFollows = toSet(graph?.inFollows);
const outOfficial = toSet(graph?.outOfficialAccounts);
const inOfficial = toSet(graph?.inOfficialAccounts);
const outKnown = toSet(graph?.outKnownPersons);
const inKnown = toSet(graph?.inKnownPersons);
const relativesGender = getRelativeGenderMap(graph);
const allMarks = getMarkByLogin(graph?.allUsers);
@@ -156,35 +138,67 @@ function buildGraphModel(graph, centerLogin) {
const allLogins = uniqueLogins([
...(graph?.outFriends || []),
...(graph?.inFriends || []),
...(graph?.outCloseFriends || []),
...(graph?.inCloseFriends || []),
...(graph?.outParents || []),
...(graph?.inParents || []),
...(graph?.outChildren || []),
...(graph?.inChildren || []),
...(graph?.outSiblings || []),
...(graph?.inSiblings || []),
...(graph?.outSpouses || []),
...(graph?.inSpouses || []),
...(graph?.outContacts || []),
...(graph?.inContacts || []),
...(graph?.outFollows || []),
...(graph?.inFollows || []),
...(graph?.outOfficialAccounts || []),
...(graph?.inOfficialAccounts || []),
...(graph?.outKnownPersons || []),
...(graph?.inKnownPersons || []),
]).filter((entry) => normKey(entry) !== normKey(login));
const relations = allLogins.map((targetLogin) => {
const parentOut = hasLogin(outParents, targetLogin);
const parentIn = hasLogin(inChildren, targetLogin);
const childOut = hasLogin(outChildren, targetLogin);
const childIn = hasLogin(inParents, targetLogin);
const siblingOut = hasLogin(outSiblings, targetLogin);
const siblingIn = hasLogin(inSiblings, targetLogin);
const spouseOut = hasLogin(outSpouses, targetLogin);
const spouseIn = hasLogin(inSpouses, targetLogin);
const friendOut = hasLogin(outFriends, targetLogin);
const friendIn = hasLogin(inFriends, targetLogin);
const closeFriendOut = hasLogin(outCloseFriends, targetLogin);
const closeFriendIn = hasLogin(inCloseFriends, targetLogin);
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outOfficial, targetLogin);
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inOfficial, targetLogin);
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, targetLogin);
let role = 'contact';
if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend';
if (parentOut || parentIn) role = 'parent';
else if (childOut || childIn) role = 'child';
else if (spouseOut || spouseIn) role = 'spouse';
else if (siblingOut || siblingIn) role = 'sibling';
else if (friendOut || friendIn) role = 'friend';
let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut;
let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn;
let forward = friendOut;
let backward = friendIn;
if (role === 'parent') {
forward = parentOut;
backward = parentIn;
} else if (role === 'child') {
forward = childOut;
backward = childIn;
} else if (role === 'spouse') {
forward = spouseOut;
backward = spouseIn;
} else if (role === 'sibling') {
forward = siblingOut;
backward = siblingIn;
} else if (role === 'contact') {
forward = contactOut;
backward = contactIn;
}
return {
login: targetLogin,
key: normKey(targetLogin),
role,
isRelative: false,
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
forward: Boolean(forward),
backward: Boolean(backward),
@@ -212,6 +226,8 @@ 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';
@@ -227,10 +243,11 @@ export function render({ navigate, route, chrome } = {}) {
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
const FILTERS = {
all: { label: 'Все', pred: () => true },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['all', 'friends', 'shining'];
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
let activeFilter = 'all';
const filterChips = {};
@@ -274,7 +291,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>
@@ -403,7 +420,6 @@ export function render({ navigate, route, chrome } = {}) {
const login = normalizeLogin(node.login);
openNodeMenu({
login,
displayName: String(node.name || '').trim(),
relationType: node.relationType,
point,
actions: [
@@ -442,34 +458,116 @@ export function render({ navigate, route, chrome } = {}) {
}
}
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({
let topMenuPortal = null;
let topMenuBackdrop = null;
let topMenuButton = null;
const closeTopMenu = () => {
topMenuPortal?.remove();
topMenuBackdrop?.remove();
topMenuPortal = null;
topMenuBackdrop = null;
topMenuButton?.setAttribute('aria-expanded', 'false');
topMenuButton?.classList.remove('menu-open-pressed');
};
const positionTopMenu = () => {
if (!topMenuPortal || !topMenuButton) return;
const rect = topMenuButton.getBoundingClientRect();
const margin = 10;
const menuRect = topMenuPortal.getBoundingClientRect();
const menuWidth = menuRect.width || topMenuPortal.offsetWidth || 190;
const menuHeight = menuRect.height || topMenuPortal.offsetHeight || 56;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
const below = rect.bottom + 7;
const top = below + menuHeight <= window.innerHeight - margin
? below
: Math.max(margin, rect.top - menuHeight - 7);
topMenuPortal.style.left = `${Math.round(left)}px`;
topMenuPortal.style.top = `${Math.round(top)}px`;
};
const openTopMenu = () => {
if (!topMenuButton || topMenuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal network-head-menu';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item network-head-menu-item" role="menuitem" data-action="find-person">
<span>Найти человека</span>
</button>
`;
portal.querySelector('[data-action="find-person"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeTopMenu();
openSearchModal();
});
portal.addEventListener('click', (event) => event.stopPropagation());
const backdrop = document.createElement('div');
backdrop.className = 'ui-menu-dim-layer';
backdrop.addEventListener('click', closeTopMenu);
document.body.append(backdrop, portal);
topMenuBackdrop = backdrop;
topMenuPortal = portal;
topMenuButton.setAttribute('aria-expanded', 'true');
topMenuButton.classList.add('menu-open-pressed');
positionTopMenu();
requestAnimationFrame(positionTopMenu);
};
const toggleTopMenu = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (topMenuPortal) closeTopMenu();
else openTopMenu();
};
const header = renderHeader({
title: 'Связи',
actions: [
rightActions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
className: 'network-top-more-btn',
title: 'Ещё действия',
ariaLabel: 'Меню связей',
onClick: toggleTopMenu,
},
],
});
// «Связи» используют тот же общий topbar, что и остальные страницы.
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
header.classList.add('network-topbar');
topMenuButton = header.querySelector('.network-top-more-btn');
topMenuButton?.setAttribute('aria-haspopup', 'menu');
topMenuButton?.setAttribute('aria-expanded', 'false');
const onTopMenuOutsideClick = (event) => {
if (!topMenuPortal) return;
if (topMenuPortal.contains(event.target) || topMenuButton?.contains(event.target)) return;
closeTopMenu();
};
const onTopMenuKeydown = (event) => {
if (event.key !== 'Escape' || !topMenuPortal) return;
closeTopMenu();
topMenuButton?.focus();
};
const onTopMenuViewportChange = () => positionTopMenu();
document.addEventListener('click', onTopMenuOutsideClick);
document.addEventListener('keydown', onTopMenuKeydown);
window.addEventListener('resize', onTopMenuViewportChange, { passive: true });
window.addEventListener('scroll', onTopMenuViewportChange, { passive: true, capture: true });
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
closeTopMenu();
document.removeEventListener('click', onTopMenuOutsideClick);
document.removeEventListener('keydown', onTopMenuKeydown);
window.removeEventListener('resize', onTopMenuViewportChange);
window.removeEventListener('scroll', onTopMenuViewportChange, true);
if (engine) engine.destroy();
engine = null;
appScreenEl?.classList.remove('network-scroll-lock');
};
if (routeLogin) {
+4 -8
View File
@@ -10,10 +10,7 @@ function normLogin(value) {
const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling'];
function relationTypeFromRelation(relation) {
const explicit = String(relation?.mark?.relationType || '').trim().toLowerCase();
if (explicit === 'close_friend' || explicit === 'friend' || explicit === 'contact') return explicit;
const role = relation?.role;
function relationTypeFromRole(role) {
if (FAMILY_ROLES.includes(role)) return 'family';
if (role === 'friend') return 'friend';
return 'contact';
@@ -25,7 +22,6 @@ function relationTypeFromRelation(relation) {
// - взаимные дружеские связи ближе односторонних.
function deriveStrength(relation) {
if (relation.isRelative) return 0.9;
if (relation?.mark?.relationType === 'close_friend') return 0.9;
if (relation.role === 'contact') return 0.4;
if (relation.forward && relation.backward) return 0.8;
return 0.55;
@@ -42,7 +38,7 @@ export function engineModelFromGraphModel(graphModel) {
const focusNode = {
id: focusLogin,
login: focusLogin,
name: centerMark?.displayName || centerMark?.login || focusLogin,
name: '',
avatar: centerMark?.avatar || null,
relationType: 'self',
strength: 1,
@@ -62,9 +58,9 @@ export function engineModelFromGraphModel(graphModel) {
return {
id: login,
login,
name: r?.mark?.displayName || login,
name: '',
avatar: r?.mark?.avatar || null,
relationType: relationTypeFromRelation(r),
relationType: relationTypeFromRole(r?.role),
strength: deriveStrength(r || {}),
shining: Boolean(r?.mark?.shine),
tier: 1,
+5 -6
View File
@@ -86,7 +86,6 @@ const DOUBLE_TAP_MS = 320; // окно двойного тапа по фо
const RELATION_COLORS = {
family: 'rgba(255, 159, 94, 0.92)',
friend: 'rgba(120, 179, 255, 0.9)',
close_friend: 'rgba(120, 179, 255, 0.98)',
business: 'rgba(190, 150, 255, 0.9)',
contact: 'rgba(170, 190, 220, 0.7)',
};
@@ -534,7 +533,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
if (dotOnly) {
el.className = [
'ui-button', 'fg-node', 'fg-dot',
'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -546,7 +545,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return el;
}
el.className = [
'ui-button', 'fg-node',
'fg-node',
isFocus ? 'is-focus' : '',
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -594,7 +593,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function updateA11y() {
const focus = nodes.find((n) => n.isFocus);
const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
const rel = { family: 'семья', friend: 'друг', close_friend: 'близкий друг', business: 'бизнес', contact: 'контакт' };
const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' };
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
}
@@ -628,8 +627,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
? ['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(' ');
? ['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(' ');
}
// --- Рендер ----------------------------------------------------------------
+3 -3
View File
@@ -31,19 +31,19 @@ export function relationLabelRu(relationType) {
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
* @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню
*/
export function openNodeMenu({ login, displayName = '', relationType, point, actions = [] } = {}) {
export function openNodeMenu({ login, relationType, point, actions = [] } = {}) {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return;
const itemsHtml = actions
.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>`)
.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>`)
.join('');
root.innerHTML = `
<div class="fg-menu-overlay" id="fg-menu-overlay">
<div class="fg-menu" id="fg-menu" role="menu">
<div class="fg-menu-head">
<span class="fg-menu-login">${escapeHtml(displayName || login)}</span>${displayName && displayName !== login ? `<small class="fg-menu-secondary-login">${escapeHtml(login)}</small>` : ''}
<span class="fg-menu-login">${escapeHtml(login)}</span>
<span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span>
</div>
${itemsHtml}
+87 -114
View File
@@ -1,41 +1,22 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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 connectionActionLabel(typeCode) {
function connectionTypeLabel(typeCode) {
switch (Number(typeCode)) {
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 'Изменил(а) связь с вами.';
case CONNECTION_CLOSE_FRIEND:
return 'близкие друзья';
default:
return 'новую связь';
}
}
function eventActionLabel(typeCode) {
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
return 'Подписался(-ась) на ваш канал.';
}
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
function normalizeItem(item) {
@@ -155,10 +136,12 @@ function renderEmpty(activeTab) {
const card = document.createElement('article');
card.className = 'card stack notification-empty-state';
const title = document.createElement('strong');
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
text.textContent = activeTab === 'events'
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
card.append(title, text);
return card;
}
@@ -256,7 +239,7 @@ function renderEngagement(engagement) {
}
function notificationRoute(item, activeTab) {
if (activeTab === 'events' || activeTab === 'connections') {
if (activeTab === 'events') {
const login = String(item?.sourceLogin || '').trim();
return login ? makeProfileRoute(login) : '';
}
@@ -295,10 +278,8 @@ function renderItem(item, activeTab, navigate) {
const action = document.createElement('p');
action.className = 'notification-action';
if (activeTab === 'connections') {
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
} else if (activeTab === 'events') {
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
if (activeTab === 'events') {
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
} else {
action.textContent = 'Ответил(а) на ваше сообщение.';
}
@@ -320,102 +301,94 @@ function renderItem(item, activeTab, navigate) {
export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'stack notifications-screen';
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
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 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 };
function countsFromPayload(payload) {
return {
replies: Number(payload?.repliesUnseenCount || 0),
connections: Number(payload?.connectionsUnseenCount || 0),
events: Number(payload?.eventsUnseenCount || 0),
};
}
async function load() {
const seq = ++requestSeq;
const activeTab = state.notificationsTab;
list.replaceChildren(renderEmpty(activeTab));
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);
}
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 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'}));
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);
}
}
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);}
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');
});
}
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);
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
// При переключении класс 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);
void load();
return screen;
}
+79 -127
View File
@@ -1,19 +1,14 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
PROFILE_GENDER_UNKNOWN,
PROFILE_ACCOUNT_ROLE_PRIMARY,
PROFILE_ACCOUNT_ROLE_NON_VOTING,
PROFILE_SHINE_SHINING,
PROFILE_SHINE_UNKNOWN,
PROFILE_SHINE_NOT_INTERESTED,
loadProfileSnapshot,
saveProfileGender,
saveProfileParamBlock,
saveProfileStatus,
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
@@ -21,17 +16,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'profile-edit-view', title: 'Редактирование профиля' };
function accountRoleLabel(value) {
if (value === PROFILE_ACCOUNT_ROLE_PRIMARY) return 'Основной аккаунт';
if (value === PROFILE_ACCOUNT_ROLE_NON_VOTING) return 'Не учитывать мой голос';
return 'Не указано';
}
function shineStatusLabel(value) {
if (value === PROFILE_SHINE_SHINING) return 'Сияющий';
if (value === PROFILE_SHINE_NOT_INTERESTED) return 'Сияние неинтересно';
if (value === PROFILE_SHINE_UNKNOWN) return 'Неизвестно';
return 'Не указано';
function toggleText(enabled) {
return enabled ? 'Yes' : 'No';
}
function showLocalErrorAlert(prefix, error) {
@@ -52,9 +38,11 @@ const GENDER_OPTIONS = Object.freeze([
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
]);
// Родственные типы сохранены в протоколе, но пока намеренно скрыты из UI.
const RELATIVE_RELATION_OPTIONS = Object.freeze([
{ value: 'friend', label: 'Друг' },
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
{ value: 'spouse', label: 'Жена / Муж (по полу)' },
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
{ value: 'close_friend', label: 'Близкий друг' },
]);
@@ -87,7 +75,6 @@ function relationAccusativeLabel(type, targetGender) {
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
return 'жену/мужа';
}
if (type === 'friend') return 'друга';
return 'близкого друга';
}
@@ -107,9 +94,9 @@ export function render({ navigate, chrome }) {
screen.className = 'stack profile-screen';
chrome?.setTopbar(
createTopBar({
renderHeader({
title: 'Редактирование профиля',
back: { label: '←', onClick: () => navigate('profile-view') },
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
@@ -133,8 +120,8 @@ export function render({ navigate, chrome }) {
const badgesRow = document.createElement('div');
badgesRow.className = 'row';
badgesRow.innerHTML = `
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="official">Официальный: No</button>
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="shine">Сияющий: No</button>
`;
const status = document.createElement('div');
@@ -147,23 +134,22 @@ export function render({ navigate, chrome }) {
const relativesCard = document.createElement('div');
relativesCard.className = 'card stack';
relativesCard.innerHTML = `
<div class="profile-param-value"><b>Друзья</b></div>
<div class="profile-param-value"><b>Близкие родственники</b></div>
<div class="meta-muted">
Добавьте пользователя в друзья или в близкие друзья.
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
Добавьте связь: родитель, ребёнок, жена/муж, брат/сестра или близкий друг.
Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя.
</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить близких родственников</button>
`;
const reloadBtn = topRow.querySelector('[data-reload="true"]');
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]');
const shineBtn = badgesRow.querySelector('[data-status="shine"]');
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
const avatarActionEl = topRow.querySelector('[data-change-avatar="true"]');
let currentFields = [];
let currentAccountRole = '';
let currentShineStatus = '';
let currentToggles = [];
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
@@ -248,76 +234,50 @@ export function render({ navigate, chrome }) {
}));
}
function updateStatusesUi() {
if (accountRoleBtn) {
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
if (!enabled) {
button.classList.add('is-no');
return;
}
if (shineBtn) {
shineBtn.textContent = `Сияние: ${shineStatusLabel(currentShineStatus)}`;
shineBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
if (currentShineStatus === PROFILE_SHINE_SHINING) shineBtn.classList.add('is-yes-shine');
else if (currentShineStatus === PROFILE_SHINE_NOT_INTERESTED) shineBtn.classList.add('is-not-interested');
else shineBtn.classList.add('is-no');
if (prefix === 'Официальный') {
button.classList.add('is-yes-official');
} else {
button.classList.add('is-yes-shine');
}
}
function updateTogglesUi() {
const official = currentToggles.find((item) => item.key === 'official') || { enabled: false };
const shine = currentToggles.find((item) => item.key === 'shine') || { enabled: false };
updateToggleButton(officialBtn, 'Официальный', official.enabled);
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
}
function updateGenderUi() {
const genderValueEl = listWrap.querySelector('[data-gender-value]');
if (!genderValueEl) return;
genderValueEl.textContent = genderLabel(currentGender);
}
function openStatusPickerModal({ title, value, options }) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
root.innerHTML = `
<div class="modal" id="profile-status-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<select class="input" id="profile-status-select">
${options.map((item) => `<option value="${escapeHtml(item.value)}" ${item.value === value ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}
</select>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-status-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-status-save" type="button">Сохранить</button>
</div>
</div>
</div>`;
return new Promise((resolve) => {
const modal = root.querySelector('#profile-status-modal');
const selectEl = root.querySelector('#profile-status-select');
const close = (next = null) => { root.innerHTML = ''; resolve(next); };
modal?.addEventListener('click', (event) => { if (event.target === modal) close(null); });
root.querySelector('#profile-status-cancel')?.addEventListener('click', () => close(null));
root.querySelector('#profile-status-save')?.addEventListener('click', () => close(selectEl?.value || null));
window.setTimeout(() => selectEl?.focus(), 0);
});
}
function openFieldEditModal({ label, value, placeholder = '', maxLength = 300, multiline = false }) {
function openFieldEditModal({ label, value, placeholder = '' }) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
root.innerHTML = `
<div class="modal" id="profile-field-edit-modal">
<div class="modal-card stack">
<h3 class="modal-title">Изменить: ${escapeHtml(label)}</h3>
${multiline ? `<textarea
id="profile-field-edit-input"
class="input"
maxlength="${Number(maxLength || 300)}"
rows="${Number(maxLength || 300) > 1000 ? 12 : 4}"
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
>${escapeHtml(String(value || ''))}</textarea>` : `<input
<input
id="profile-field-edit-input"
class="input"
type="text"
maxlength="${Number(maxLength || 300)}"
maxlength="300"
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
value="${escapeHtml(String(value || ''))}"
/>`}
<div class="meta-muted">До ${Number(maxLength || 300)} символов.</div>
/>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-field-edit-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-field-edit-save" type="button">Сохранить</button>
@@ -331,7 +291,7 @@ export function render({ navigate, chrome }) {
const inputEl = root.querySelector('#profile-field-edit-input');
const saveEl = root.querySelector('#profile-field-edit-save');
const cancelEl = root.querySelector('#profile-field-edit-cancel');
if (!(modal instanceof HTMLElement) || (!(inputEl instanceof HTMLInputElement) && !(inputEl instanceof HTMLTextAreaElement))) {
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement)) {
root.innerHTML = '';
resolve(null);
return;
@@ -348,7 +308,7 @@ export function render({ navigate, chrome }) {
cancelEl?.addEventListener('click', () => close(null));
saveEl?.addEventListener('click', () => close(inputEl.value));
inputEl.addEventListener('keydown', (event) => {
if (!multiline && event.key === 'Enter') {
if (event.key === 'Enter') {
event.preventDefault();
close(inputEl.value);
}
@@ -454,7 +414,7 @@ export function render({ navigate, chrome }) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = values.map((value) => (
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
)).join('');
};
@@ -607,21 +567,20 @@ export function render({ navigate, chrome }) {
status.className = 'status-line';
status.textContent = 'Загрузка параметров...';
reloadBtn.disabled = true;
accountRoleBtn.disabled = true;
officialBtn.disabled = true;
shineBtn.disabled = true;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
try {
const snapshot = await loadProfileSnapshot(login);
currentFields = snapshot.fields;
currentAccountRole = snapshot.accountRole || '';
currentShineStatus = snapshot.shineStatus || '';
currentToggles = snapshot.toggles;
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
syncIdentity();
renderFields(currentFields);
updateStatusesUi();
updateTogglesUi();
updateGenderUi();
updateAvatarUi();
@@ -633,7 +592,7 @@ export function render({ navigate, chrome }) {
showLocalErrorAlert('Ошибка загрузки параметров профиля', error);
} finally {
reloadBtn.disabled = false;
accountRoleBtn.disabled = false;
officialBtn.disabled = false;
shineBtn.disabled = false;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
}
@@ -670,32 +629,27 @@ export function render({ navigate, chrome }) {
}
}
async function onStatusClick(statusKey) {
const isAccountRole = statusKey === 'account_role';
const picked = await openStatusPickerModal({
title: isAccountRole ? 'Роль аккаунта' : 'Статус сияния',
value: isAccountRole ? currentAccountRole : currentShineStatus,
options: isAccountRole
? [
{ value: PROFILE_ACCOUNT_ROLE_PRIMARY, label: 'Основной аккаунт' },
{ value: PROFILE_ACCOUNT_ROLE_NON_VOTING, label: 'Не учитывать мой голос' },
]
: [
{ value: PROFILE_SHINE_SHINING, label: 'Сияющий' },
{ value: PROFILE_SHINE_UNKNOWN, label: 'Неизвестно' },
{ value: PROFILE_SHINE_NOT_INTERESTED, label: 'Сияние мне неинтересно' },
],
});
if (!picked) return;
async function onToggleClick(toggleKey) {
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
const nextEnabled = !toggle.enabled;
const title = toggleKey === 'official' ? 'официальный' : 'сияющий';
const confirmed = window.confirm(
`Хотите изменить «${title}» на ${toggleText(nextEnabled)}?\n` +
'Будет создана запись в блокчейне.',
);
if (!confirmed) return;
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
try {
await saveProfileStatus(login, statusKey, picked);
await saveProfileToggle(login, toggleKey, nextEnabled);
await refreshProfileSnapshot();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось изменить ${statusKey}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${statusKey}`, error);
status.textContent = `Не удалось изменить ${toggleKey}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${toggleKey}`, error);
}
}
@@ -707,14 +661,8 @@ export function render({ navigate, chrome }) {
label: field.label,
value: field.value || '',
placeholder: field.placeholder || '',
maxLength: field.maxLength || 300,
multiline: Boolean(field.multiline),
});
if (entered === null) return;
if (String(entered).length > Number(field.maxLength || 300)) {
window.alert(`Максимальная длина поля «${field.label}» — ${Number(field.maxLength || 300)} символов.`);
return;
}
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
@@ -805,16 +753,20 @@ export function render({ navigate, chrome }) {
status.textContent = 'Сохранение связи...';
try {
if (!state.session.storagePwdInMemory) {
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
if (relationType === 'close_friend') {
await authService.addCloseFriend(targetLogin);
} else {
if (!state.session.storagePwdInMemory) {
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
}
await authService.setUserRelation({
login: sessionLogin,
toLogin: targetLogin,
kind: relationType,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
await authService.setUserRelation({
login: sessionLogin,
toLogin: targetLogin,
kind: relationType,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
status.className = 'status-line is-available';
status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`;
} catch (error) {
@@ -839,8 +791,8 @@ export function render({ navigate, chrome }) {
});
reloadBtn.addEventListener('click', refreshProfileSnapshot);
accountRoleBtn.addEventListener('click', () => onStatusClick('account_role'));
shineBtn.addEventListener('click', () => onStatusClick('shine'));
officialBtn.addEventListener('click', () => onToggleClick('official'));
shineBtn.addEventListener('click', () => onToggleClick('shine'));
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
avatarActionEl?.addEventListener('click', () => { void onChangeAvatarClick(); });
+277 -178
View File
@@ -1,12 +1,27 @@
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { authService, state } from '../state.js';
import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
loadProfileSnapshot,
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
function toggleText(enabled) {
return enabled ? 'Yes' : 'No';
}
function genderLabel(value) {
if (value === PROFILE_GENDER_MALE) return 'Мужской';
if (value === PROFILE_GENDER_FEMALE) return 'Женский';
return 'Не указан';
}
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
@@ -16,209 +31,293 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
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 spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
function renderProfileInfoText(text) {
const [intro, details] = String(text || '').split(/\n\n/, 2);
if (!details) {
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`;
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
const [sectionTitle, ...items] = details.split('\n').filter(Boolean);
return `
<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>
<section class="profile-info-modal__section" aria-label="${escapeHtml(sectionTitle)}">
<p class="profile-info-modal__section-title">${escapeHtml(sectionTitle)}</p>
<ol class="profile-info-modal__list">
${items.map((item) => `<li>${escapeHtml(item.replace(/^\d+\)\s*/, ''))}</li>`).join('')}
</ol>
</section>
`;
}
function openProfileInfoModal({ title, text }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal profile-info-modal" id="profile-info-modal" role="dialog" aria-modal="true" aria-labelledby="profile-info-title">
<section class="modal-card profile-info-modal__card stack">
<header class="profile-info-modal__header">
<div>
<p class="profile-info-modal__eyebrow">Информация</p>
<h3 class="modal-title profile-info-modal__title" id="profile-info-title">${escapeHtml(title)}</h3>
</div>
<button class="profile-info-modal__close" type="button" id="profile-info-close-icon" aria-label="Закрыть" title="Закрыть">&times;</button>
</header>
<div class="profile-info-modal__content">
${renderProfileInfoText(text)}
</div>
<footer class="profile-info-modal__footer">
<button class="secondary-btn profile-info-modal__confirm" type="button" id="profile-info-close">Готово</button>
</footer>
</section>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close);
root.querySelector('#profile-info-close-icon')?.addEventListener('click', close);
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-info-modal') close();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const login = state.session.login || profile.login;
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') },
],
},
},
const screen = document.createElement('section');
screen.className = 'stack 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 profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
profileMenuButton?.append(createOverflowDots());
const profileMenu = createDropdownMenu({
anchorEl: profileMenuButton,
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') },
],
});
chrome?.setTopbar(topbar);
const status = document.createElement('div');
status.className = 'status-line user-profile-status';
status.textContent = 'Загрузка профиля...';
const card = document.createElement('div');
card.className = 'card stack profile-main-card';
const body = document.createElement('div');
body.className = 'user-profile-body';
screen.append(status, body);
let card = null;
function renderProfile() {
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();
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
const topRow = document.createElement('div');
topRow.className = 'row';
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div data-profile-avatar-slot="true"></div>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
`;
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
const badgesRow = document.createElement('div');
badgesRow.className = 'row';
badgesRow.innerHTML = `
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="official">Официальный: No</button>
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="shine">Сияющий: No</button>
`;
<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>
const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list';
<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>
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
<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>`;
let currentFields = [];
let currentToggles = [];
let currentGender = 'unknown';
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
let currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
avatar: card.avatar,
function syncIdentity() {
if (!identityEl) return;
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
const lines = buildIdentityLines({ login, firstName, lastName });
identityEl.innerHTML = lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('');
}
function updateAvatarUi() {
if (!(avatarSlotEl instanceof HTMLElement)) return;
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
avatarSlotEl.innerHTML = '';
avatarSlotEl.append(renderUserAvatar({
login,
firstName,
lastName,
avatar: currentAvatar?.txId
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
: null,
size: 'xl',
className: 'user-profile-hero-avatar',
glow: shining,
className: 'profile-avatar',
}));
status.textContent = '';
}
body.addEventListener('click', (event) => {
if (!card) return;
const listButton = event.target.closest('[data-profile-list]');
if (listButton) {
const kind = listButton.dataset.profileList;
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
if (!enabled) {
button.classList.add('is-no');
return;
}
if (prefix === 'Официальный') button.classList.add('is-yes-official');
else button.classList.add('is-yes-shine');
}
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;
}
function updateTogglesUi() {
const official = currentToggles.find((item) => item.key === 'official') || { enabled: false };
const shine = currentToggles.find((item) => item.key === 'shine') || { enabled: false };
updateToggleButton(officialBtn, 'Официальный', official.enabled);
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
}
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');
function renderStats() {
const stats = [
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
];
stats.forEach((stat) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
listWrap.append(row);
});
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() {
if (state.session.isLocalDemo) {
status.textContent = 'Локальный тестовый режим.';
return;
}
card = await loadUserProfileCard(login);
renderProfile();
}
refresh().catch((error) => {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
officialBtn?.classList.add('profile-badge-trigger');
shineBtn?.classList.add('profile-badge-trigger');
officialBtn?.addEventListener('click', () => {
openProfileInfoModal({
title: 'Официальный канал',
text: officialInfoText(),
});
});
shineBtn?.addEventListener('click', () => {
openProfileInfoModal({
title: 'Справка о сияющих',
text: shineInfoText(),
});
});
function renderFields(fields) {
listWrap.innerHTML = '';
renderStats();
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
const value = String(field.value || '').trim() || 'не заполнено';
row.innerHTML = `<div class="profile-param-value"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
listWrap.append(row);
if (field.key === 'last_name') {
const genderRow = document.createElement('div');
genderRow.className = 'card profile-param-item row';
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: ${escapeHtml(genderLabel(currentGender))}</div>`;
listWrap.append(genderRow);
}
});
}
async function refreshProfileSnapshot() {
if (state.session.isLocalDemo) {
currentFields = [
{ key: 'first_name', label: 'Имя', value: 'Тестовый' },
{ key: 'last_name', label: 'Фамилия', value: 'Пользователь' },
{ key: 'address', label: 'Адрес', value: 'Локальный режим' },
{ key: 'website', label: 'Веб', value: '127.0.0.1' },
{ key: 'phone', label: 'Телефон', value: profile.phone },
];
currentToggles = [
{ key: 'official', enabled: false },
{ key: 'shine', enabled: false },
];
currentGender = 'unknown';
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
renderFields(currentFields);
return;
}
try {
const [snapshot, user] = await Promise.all([
loadProfileSnapshot(login),
authService.getUser(login).catch(() => ({})),
]);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
currentGender = snapshot.gender || 'unknown';
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingUsersCount: Number(user?.followingUsersCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
renderFields(currentFields);
} catch (error) {
// ignore status row in profile-view
}
}
card.append(topRow, badgesRow, listWrap);
screen.append(card);
updateAvatarUi();
refreshProfileSnapshot();
screen.cleanup = () => profileMenu.destroy();
return screen;
}
-156
View File
@@ -1,156 +0,0 @@
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 { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.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, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -404,11 +404,11 @@ export function render({navigate, chrome}) {
});
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
chrome?.setTopbar(createTopBar({
title: 'Очередь билета',
back: { label: '←', onClick: () => navigate('start-view') },
}));
screen.append(
renderHeader({
title: 'Очередь билета',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
content,
);
+7 -5
View File
@@ -1,4 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
@@ -265,14 +265,16 @@ function buildRecommendations(diag) {
return rows;
}
export function render({navigate, chrome}) {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
screen.append(
renderHeader({
title: 'Диагностика PWA / Push',
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
const statusCard = document.createElement('div');
statusCard.className = 'card stack';

Some files were not shown because too many files have changed in this diff Show More