Compare commits

..
Author SHA256 Message Date
tram 09c7f8541e Вернуть профильные элементы 2026-09-02 15:02:48 +03:00
tram 8eb2ef438c Восстановить профиль пользователя 2026-09-02 14:53:11 +03:00
tram 34abe14151 Доработать профиль пользователя 2026-09-02 14:42:58 +03:00
AidarKC 90e37c198f Adjust DM contact card spacing 2026-09-02 14:58:17 +04:00
AidarKC 1373283947 Refine direct message profile data 2026-09-02 14:56:33 +04:00
AidarKC 065616a18b Update DM and profile handling 2026-09-02 14:24:44 +04:00
AidarKC 53cd55a2a0 Fix DM name fallbacks and chat header 2026-09-02 14:23:42 +04:00
AidarKC 079be37fff Починить ListUserProfileChannels для PostgreSQL 2026-09-02 13:09:05 +04:00
AidarKC 806a8c57d4 Merge remote-tracking branch 'origin/main' 2026-09-02 12:52:54 +04:00
tram 797e769cc3 Merge branch 'ui-artem'
# Conflicts:
#	shine-UI/styles/components.css
2026-09-02 11:45:49 +03:00
tram 822a72e246 Доработать профиль пользователя 2026-09-02 11:43:47 +03:00
AidarKC 58b564b385 Промежуточная версия: UI еще доделать и мелкие ошибки 2026-09-01 19:07:59 +04:00
AidarKC 7aa9e2f6a4 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 19:07:41 +04:00
AidarKC f645686d87 Revert "Исправить отображение имени в личных сообщениях"
This reverts commit 6fcaa5d3d4.
2026-09-01 19:00:05 +04:00
AidarKC 6fcaa5d3d4 Исправить отображение имени в личных сообщениях 2026-09-01 18:55:20 +04:00
AidarKC 5d19e17a0b Исправить открытие личных сообщений 2026-09-01 18:50:18 +04:00
AidarKC 52a9f1ac1d Обновить UI сети и профиль 2026-09-01 18:45:32 +04:00
AidarKC 3dec5e3225 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:35:21 +04:00
AidarKC 90ded1612a Merge remote-tracking branch 'origin/main' 2026-09-01 18:35:14 +04:00
tram 7ab9fb7c28 Обновить UI сети и серверные экраны 2026-09-01 17:23:31 +03:00
AidarKC 6f499c68e2 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:19:47 +04:00
AidarKC 19beba8fd7 Обновить версии после merge в main 2026-09-01 18:17:42 +04:00
AidarKC f7e33802c8 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 18:17:20 +04:00
AidarKC c03d94105c Доделать профиль, связи и серверные миграции 2026-09-01 18:14:55 +04:00
AidarKC 632d56737b UI: доработать профиль и связи 2026-09-01 15:24:39 +04:00
tram 8801b93973 Обновить UI сети и серверные экраны 2026-09-01 14:04:12 +03:00
AidarKC 390a2b988e Доделать связи и профиль 2026-09-01 14:59:42 +04:00
AidarKC a66d7a64e6 Исправить дублирование SVG_CHEVRON в личных сообщениях 2026-09-01 13:49:51 +04:00
AidarKC a8596d3350 Смерджить расширение функционала каналов в main 2026-09-01 13:14:18 +04:00
AidarKC 8a5dabee95 Переделал UI отображения связей 2026-09-01 12:57:04 +04:00
AidarKC da859468d3 Обновить стили списка каналов 2026-08-29 18:03:01 +04:00
AidarKC 78ddb8ffb6 Исправить рендер messages-list и поднять версию для main 2026-08-23 12:07:37 +04:00
AidarKC db37f35d09 Версии: поднять client и server для merge в main 2026-08-23 11:38:07 +04:00
AidarKC d8f5fe4fd0 Merge branch 'расширение-функционала-каналов' 2026-08-23 11:37:52 +04:00
63 changed files with 4230 additions and 1361 deletions
@@ -102,16 +102,23 @@ 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;
/** Удалить связь "жена/муж". */
@@ -132,9 +139,8 @@ 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;
/** Точно уверен, что сияющий. */
@@ -142,11 +148,17 @@ 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,9 +20,11 @@ import java.util.Objects;
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode
@@ -185,6 +187,8 @@ 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)
@@ -202,7 +206,9 @@ 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_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
}
@Override
@@ -33,6 +33,9 @@ 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 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";
@@ -49,6 +52,9 @@ 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";
private DatabaseInitializer() {}
@@ -70,12 +76,17 @@ 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;
@@ -88,15 +99,20 @@ 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 {
@@ -172,6 +188,18 @@ 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;
}
}
}
@@ -58,10 +58,10 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/**
* Совпадает с ConnectionBody:
* 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
* 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
*/
/** Добавить в близкие друзья (close friend). */
@@ -70,18 +70,26 @@ 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;
@@ -106,10 +114,8 @@ 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;
/** Точно уверен, что сияющий. */
@@ -118,12 +124,19 @@ 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,6 +426,25 @@ 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,6 +64,7 @@ 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("""
@@ -95,6 +96,9 @@ 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);
}
@@ -286,6 +290,9 @@ 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";
}
@@ -357,6 +364,8 @@ 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 {
@@ -433,7 +442,7 @@ public final class DmDialogStateDAO {
private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT);
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
return "none";
}
@@ -55,6 +55,7 @@ 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());
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
if (e.getSignature() != null) ps.setString(6, e.getSignature());
else ps.setNull(6, Types.VARCHAR);
return ps.executeUpdate();
changed = ps.executeUpdate();
}
if (changed > 0 && affectsFastProfile(e.getParam())) {
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
}
return changed;
}
public int upsertIfNewer(UserParamEntry e) throws SQLException {
@@ -77,6 +82,32 @@ 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 {
@@ -0,0 +1,142 @@
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 LOWER(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 LOWER(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){}
}
@@ -0,0 +1,381 @@
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;
@@ -0,0 +1,348 @@
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;
@@ -0,0 +1,83 @@
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;
@@ -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, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
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;
@@ -1113,7 +1113,7 @@ BEGIN
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
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;
@@ -1288,6 +1288,7 @@ 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
@@ -1297,6 +1298,7 @@ BEGIN
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
WHEN 81 THEN 80
ELSE NULL
END;
@@ -1644,4 +1646,359 @@ 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));
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;
@@ -88,6 +88,10 @@ 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;
@@ -207,6 +211,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()),
@@ -295,6 +301,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),
@@ -563,6 +563,13 @@ 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())
@@ -609,6 +616,31 @@ 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);
@@ -885,10 +917,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
// 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.
// Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
&& (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF))
&& block.body instanceof ConnectionBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("connection");
@@ -138,6 +138,17 @@ 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,8 +342,10 @@ final class ChannelsReadSupport {
}
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT 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,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";
int likesCount = 0;
int primaryLikesCount = 0;
int shiningLikesCount = 0;
int repliesCount = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
@@ -352,6 +354,8 @@ 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");
}
}
@@ -378,7 +382,7 @@ final class ChannelsReadSupport {
}
}
}
return new int[] {likesCount, repliesCount, ratingsCount};
return new int[] {likesCount, repliesCount, ratingsCount, primaryLikesCount, shiningLikesCount};
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
@@ -169,6 +169,8 @@ 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,6 +242,8 @@ 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));
@@ -139,6 +139,8 @@ 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;
@@ -191,6 +193,10 @@ 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,8 +46,10 @@ 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_CLOSE_FRIEND);
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
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> 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);
@@ -60,18 +62,21 @@ 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);
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);
// 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> 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, outContacts, inContacts, outFollows, inFollows,
addAllLogins(allLogins, outFriends, inFriends, outCloseFriends, inCloseFriends, outContacts, inContacts, outFollows, inFollows,
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
outKnownPersons, inKnownPersons, outOfficialAccounts, inOfficialAccounts, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
@@ -86,6 +91,8 @@ 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);
@@ -100,6 +107,8 @@ 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);
@@ -108,11 +117,49 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
resp.setSpouses(toRelativeItems(spouseLogins, metaByLogin));
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin));
Map<String, String> relationTypes = buildEffectiveRelationTypes(outContacts, outFriends, outCloseFriends);
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin, relationTypes, outOfficialAccounts, outShineConfirmed));
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";
@@ -160,47 +207,54 @@ 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 += 1) placeholders[i] = "?";
for (int i=0;i<placeholders.length;i++) placeholders[i]="?";
String sql = """
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
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
FROM %s
LEFT JOIN users_params up
ON LOWER(up.login) = LOWER(su.login)
AND up.param IN ('gender', 'official', 'shine', 'ava')
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'
WHERE LOWER(su.login) IN (%s)
GROUP BY su.login
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
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));
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);
}
}
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);
}}
}
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);
@@ -269,7 +323,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private List<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
Set<String> logins,
Map<String, UserMeta> metaByLogin
Map<String, UserMeta> metaByLogin,
Map<String, String> relationTypes,
List<String> primaryConfirmedLogins,
List<String> shineConfirmedLogins
) {
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> items = new ArrayList<>();
if (logins == null) return items;
@@ -284,6 +341,15 @@ 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 ? "официальный" : "неофициальный");
@@ -296,6 +362,12 @@ 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,6 +9,7 @@ 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;
@@ -29,18 +30,25 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin());
resp.setDialogs(toDialogItems(dialogs));
resp.setDialogs(toDialogItems(c, dialogs));
return resp;
}
}
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
private List<Net_ListContacts_Response.DialogItem> toDialogItems(Connection c, List<DmDialogStateDAO.DialogSummary> dialogs) throws Exception {
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());
@@ -49,4 +57,14 @@ 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,6 +9,8 @@ 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<>();
@@ -23,6 +25,8 @@ 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<>();
@@ -58,6 +62,15 @@ 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;
@@ -66,6 +79,24 @@ 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; }
@@ -84,6 +115,10 @@ 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; }
@@ -112,6 +147,10 @@ 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,6 +17,12 @@ 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;
@@ -26,6 +32,18 @@ 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; }
@@ -35,4 +53,13 @@ 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; }
}
}
@@ -0,0 +1,3 @@
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;}}}
@@ -0,0 +1,14 @@
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;
}
}
}
@@ -0,0 +1,3 @@
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;}}
@@ -0,0 +1,3 @@
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;}}}
@@ -0,0 +1,9 @@
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;}
}
@@ -0,0 +1,24 @@
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,11 +136,21 @@ 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
close_friends_count,
friends_count,
primary_confirmations_received_count,
primary_confirmations_given_count,
shine_confirmations_received_count,
shine_confirmations_given_count
FROM user_stats_state
WHERE login = ?
LIMIT 1
@@ -156,6 +166,11 @@ 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,6 +47,11 @@ 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; }
@@ -89,5 +94,15 @@ 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,6 +11,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.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;
@@ -74,6 +78,21 @@ 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();
@@ -108,4 +127,21 @@ 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,6 +12,7 @@ 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; }
@@ -26,9 +27,46 @@ 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.7.6
server.version=1.6.2
client.version=1.12.0
server.version=1.10.0
+28
View File
@@ -416,3 +416,31 @@
- `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.
+42
View File
@@ -205,3 +205,45 @@
}
}
```
---
## 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`.
@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 405 B

+1 -1
View File
@@ -12,7 +12,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260826145335';
window.__SHINE_BUILD_HASH__ = '20260901134200';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
+4
View File
@@ -91,6 +91,8 @@ 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';
@@ -154,6 +156,8 @@ 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,
+6 -20
View File
@@ -775,6 +775,8 @@ 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();
@@ -918,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
@@ -962,24 +964,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
});
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),
});
});
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
const shareButton = document.createElement('button');
shareButton.type = 'button';
@@ -997,7 +983,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, ratingButton, shareButton);
actions.append(likeButton, replyButton, shareButton);
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
+7 -22
View File
@@ -1417,6 +1417,8 @@ 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),
@@ -1961,7 +1963,7 @@ function renderPostCard(post, {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
`;
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
@@ -1996,27 +1998,10 @@ function renderPostCard(post, {
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
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);
// 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 shareButton = document.createElement('button');
shareButton.type = 'button';
+15 -2
View File
@@ -52,6 +52,18 @@ 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);
@@ -719,6 +731,7 @@ 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,
@@ -737,10 +750,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
lastMessageAt: Number(lastMessage.createdAtMs || 0),
isOwnChannel: isOwn,
isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true,
+111 -83
View File
@@ -58,11 +58,11 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
<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>
<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>
<span>Показать профиль</span>
</button>
</div>
</div>
@@ -101,63 +101,93 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
});
}
function normalizeChatRelationType(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none';
}
function chatRelationLabel(value) {
switch (normalizeChatRelationType(value)) {
case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг';
case 'contact': return 'Контакт';
default: return 'Не в контактах';
}
}
function createChatHeaderParts(login, navigate) {
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 chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
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,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
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
firstName,
lastName,
avatar: currentPeer?.avatar?.ar
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
ar: String(currentPeer.avatar.ar || '').trim(),
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
})
.catch(() => {});
avatarSlot.replaceChildren(avatar);
};
const openMenu = (event) => {
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();
identityButton.addEventListener('click', (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 };
return {
centerNode: identityButton,
updatePeer,
getPeer: () => ({ ...currentPeer }),
};
}
function truncatePreviewText(value, maxLen = 72) {
@@ -373,8 +403,10 @@ function openMessageActionsMenu({
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
showAddContact = false,
onCall,
onVideoCall,
onAddContact,
onClearHistory,
onDeleteChat,
}) {
@@ -387,6 +419,7 @@ function openChatActionsMenu({
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true"></span><span>Добавить в контакты</span></button>` : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
</div>
@@ -438,6 +471,10 @@ function openChatActionsMenu({
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
close();
if (typeof onAddContact === 'function') await onAddContact();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -960,6 +997,7 @@ 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;
@@ -1054,6 +1092,24 @@ 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');
@@ -1090,8 +1146,16 @@ export function render({ navigate, route, chrome }) {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onAddContact: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -1139,48 +1203,8 @@ export function render({ navigate, route, chrome }) {
},
],
});
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';
@@ -1572,6 +1596,10 @@ 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);
+37 -9
View File
@@ -17,16 +17,21 @@ import { formatRelativeTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
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"></path>
</svg>
`;
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],
['contact', 1],
['none', 2],
['friend', 1],
['contact', 2],
['none', 3],
]);
async function loadDmAvatarSnapshot(login) {
@@ -50,16 +55,19 @@ async function loadDmAvatarSnapshot(login) {
return pending;
}
function createDmAvatar(login, { className = '' } = {}) {
function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
firstName: String(firstName || '').trim(),
lastName: String(lastName || '').trim(),
avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null,
size: 'lg',
title,
className,
});
if (!cleanLogin) return avatarEl;
if (!cleanLogin || avatar?.ar) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
@@ -82,7 +90,7 @@ function createDmAvatar(login, { className = '' } = {}) {
function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'contact') return clean;
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none';
}
@@ -94,6 +102,8 @@ function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'friend':
return 'друг';
case 'contact':
return 'контакт';
default:
@@ -187,6 +197,7 @@ function compareChatRows(a, b) {
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const head = document.createElement('header');
@@ -220,6 +231,7 @@ export function render({ navigate, chrome }) {
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
friend: 'Друзья',
contact: 'Контакты',
none: 'Новые',
};
@@ -232,6 +244,7 @@ export function render({ navigate, chrome }) {
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(); } },
],
@@ -333,7 +346,11 @@ function renderRow(item) {
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin);
const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar,
firstName: item.firstName,
lastName: item.lastName,
});
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
@@ -357,7 +374,10 @@ 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) titleEl.textContent = String(item.peerLogin || '');
if (titleEl) {
const fullName = [String(item.firstName || '').trim(), String(item.lastName || '').trim()].filter(Boolean).join(' ');
titleEl.textContent = fullName || String(item.peerLogin || '');
}
if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap);
@@ -381,7 +401,10 @@ function renderRow(item) {
try {
const payload = await authService.listContacts();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
const contacts = dialogs
.filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none')
.map((dialog) => String(dialog?.peerLogin || '').trim())
.filter(Boolean);
setContacts(contacts);
list.innerHTML = '';
@@ -395,6 +418,11 @@ 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),
+64 -96
View File
@@ -4,7 +4,8 @@ import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { createForceGraph } from './network/force-graph.js';
import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
import { openNodeMenu } from './network/node-menu.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
@@ -24,6 +25,32 @@ function createDebounced(fn, delayMs = 2000) {
};
}
function createHeaderSearchIcon() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'header-icon-svg header-icon-svg--search');
const circle = document.createElementNS(ns, 'circle');
circle.setAttribute('cx', '11');
circle.setAttribute('cy', '11');
circle.setAttribute('r', '6.5');
circle.setAttribute('fill', 'none');
circle.setAttribute('stroke', 'currentColor');
circle.setAttribute('stroke-width', '2');
const handle = document.createElementNS(ns, 'path');
handle.setAttribute('d', 'M16 16l4.5 4.5');
handle.setAttribute('fill', 'none');
handle.setAttribute('stroke', 'currentColor');
handle.setAttribute('stroke-width', '2');
handle.setAttribute('stroke-linecap', 'round');
svg.append(circle, handle);
return svg;
}
function normKey(value) {
return normalizeLogin(value).toLowerCase();
}
@@ -73,6 +100,12 @@ 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 ? 'официальный' : 'неофициальный')),
@@ -104,10 +137,8 @@ function applyRelativeGender(map, rows) {
function getRelativeGenderMap(graph) {
const map = new Map();
applyRelativeGender(map, graph?.parents);
applyRelativeGender(map, graph?.children);
applyRelativeGender(map, graph?.siblings);
applyRelativeGender(map, graph?.spouses);
// Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды.
void graph;
return map;
}
@@ -115,6 +146,8 @@ 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);
@@ -128,8 +161,8 @@ function buildGraphModel(graph, centerLogin) {
const inContacts = toSet(graph?.inContacts);
const outFollows = toSet(graph?.outFollows);
const inFollows = toSet(graph?.inFollows);
const outKnown = toSet(graph?.outKnownPersons);
const inKnown = toSet(graph?.inKnownPersons);
const outOfficial = toSet(graph?.outOfficialAccounts);
const inOfficial = toSet(graph?.inOfficialAccounts);
const relativesGender = getRelativeGenderMap(graph);
const allMarks = getMarkByLogin(graph?.allUsers);
@@ -137,67 +170,35 @@ function buildGraphModel(graph, centerLogin) {
const allLogins = uniqueLogins([
...(graph?.outFriends || []),
...(graph?.inFriends || []),
...(graph?.outParents || []),
...(graph?.inParents || []),
...(graph?.outChildren || []),
...(graph?.inChildren || []),
...(graph?.outSiblings || []),
...(graph?.inSiblings || []),
...(graph?.outSpouses || []),
...(graph?.inSpouses || []),
...(graph?.outCloseFriends || []),
...(graph?.inCloseFriends || []),
...(graph?.outContacts || []),
...(graph?.inContacts || []),
...(graph?.outFollows || []),
...(graph?.inFollows || []),
...(graph?.outKnownPersons || []),
...(graph?.inKnownPersons || []),
...(graph?.outOfficialAccounts || []),
...(graph?.inOfficialAccounts || []),
]).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 contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, 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);
let role = 'contact';
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';
if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend';
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;
}
let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut;
let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn;
return {
login: targetLogin,
key: normKey(targetLogin),
role,
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
isRelative: false,
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
forward: Boolean(forward),
backward: Boolean(backward),
@@ -237,17 +238,15 @@ export function render({ navigate, route, chrome } = {}) {
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
let engine = null;
let sheetEl = null;
let loadSeq = 0;
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
const FILTERS = {
all: { label: 'Все', pred: () => true },
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
const FILTER_ORDER = ['all', 'friends', 'shining'];
let activeFilter = 'all';
const filterChips = {};
@@ -400,41 +399,6 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => inputEl.focus(), 0);
}
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
function showNodeSheet(node) {
if (!sheetEl) {
sheetEl = document.createElement('div');
sheetEl.className = 'fg-sheet';
stage.append(sheetEl);
}
const login = normalizeLogin(node.login);
const shineBadge = node.shining ? '<span class="fg-sheet-badge">сияющий</span>' : '';
sheetEl.innerHTML = `
<button class="fg-sheet-close" type="button" data-act="close" aria-label="Закрыть"></button>
<div class="fg-sheet-body">
<div class="fg-sheet-title">${escapeHtml(login)} ${shineBadge}</div>
<div class="fg-sheet-rel">${escapeHtml(relationLabelRu(node.relationType))}</div>
</div>
<div class="fg-sheet-actions">
<button class="ghost-btn" type="button" data-act="profile">Профиль</button>
<button class="primary-btn" type="button" data-act="write">Написать</button>
</div>
`;
sheetEl.classList.add('is-open');
sheetEl.onclick = (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null;
if (!(btn instanceof HTMLElement)) return;
const act = btn.dataset.act;
if (act === 'close') hideNodeSheet();
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
else if (act === 'write') navigate(`chat/${encodeURIComponent(login)}`);
};
}
function hideNodeSheet() {
if (sheetEl) sheetEl.classList.remove('is-open');
}
function ensureEngine(model) {
if (engine) {
engine.setModel(model);
@@ -443,8 +407,8 @@ export function render({ navigate, route, chrome } = {}) {
engine = createForceGraph({
stage: board,
model,
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
// тап по центру — полноценный профиль
onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login);
@@ -455,6 +419,7 @@ export function render({ navigate, route, chrome } = {}) {
const login = normalizeLogin(node.login);
openNodeMenu({
login,
displayName: String(node.name || '').trim(),
relationType: node.relationType,
point,
actions: [
@@ -496,12 +461,15 @@ export function render({ navigate, route, chrome } = {}) {
const header = renderHeader({
title: 'Связи',
rightActions: [
{ label: 'Найти', onClick: openSearchModal },
{
iconNode: createHeaderSearchIcon(),
title: 'Найти пользователя',
ariaLabel: 'Найти пользователя',
className: 'chat-header-icon-btn',
onClick: openSearchModal,
},
],
});
// «Связи» используют тот же общий topbar, что и остальные страницы.
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
header.classList.add('network-topbar');
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
+8 -4
View File
@@ -10,7 +10,10 @@ function normLogin(value) {
const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling'];
function relationTypeFromRole(role) {
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;
if (FAMILY_ROLES.includes(role)) return 'family';
if (role === 'friend') return 'friend';
return 'contact';
@@ -22,6 +25,7 @@ function relationTypeFromRole(role) {
// - взаимные дружеские связи ближе односторонних.
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;
@@ -38,7 +42,7 @@ export function engineModelFromGraphModel(graphModel) {
const focusNode = {
id: focusLogin,
login: focusLogin,
name: '',
name: centerMark?.displayName || centerMark?.login || focusLogin,
avatar: centerMark?.avatar || null,
relationType: 'self',
strength: 1,
@@ -58,9 +62,9 @@ export function engineModelFromGraphModel(graphModel) {
return {
id: login,
login,
name: '',
name: r?.mark?.displayName || login,
avatar: r?.mark?.avatar || null,
relationType: relationTypeFromRole(r?.role),
relationType: relationTypeFromRelation(r),
strength: deriveStrength(r || {}),
shining: Boolean(r?.mark?.shine),
tier: 1,
+74 -34
View File
@@ -21,8 +21,9 @@ import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
const SVGNS = 'http://www.w3.org/2000/svg';
// --- Параметры физики и анимации ---------------------------------------------
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
const ORBIT_FIRST_R = 100; // первый круг связей: был ~150px, теперь на треть ближе к центру
const ORBIT_GAP = 87; // между соседними кругами >= 1.5 диаметра аватарки (58px * 1.5)
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
@@ -85,6 +86,7 @@ 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)',
};
@@ -146,11 +148,57 @@ function ensureShineFilter() {
document.body.appendChild(svg);
}
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
function spreadAngle(index, total) {
// Равномерный угол по кругу. Небольшой постоянный сдвиг, чтобы первый узел
// не «прилипал» к горизонтали.
function spreadAngle(index, total, phase = 0.52) {
if (total <= 0) return 0;
return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
return ((index / total) * Math.PI * 2 + phase) % (Math.PI * 2);
}
// Сколько аватарок гарантированно помещается на круге радиуса r, если расстояние
// между ЦЕНТРАМИ соседних аватарок должно быть не меньше ORBIT_NODE_GAP.
// Используем хорду, а не длину дуги — поэтому условие выполняется геометрически точно.
function orbitCapacity(radius) {
const r = Math.max(radius, ORBIT_NODE_GAP / 2 + 1);
const minAngle = 2 * Math.asin(Math.min(1, ORBIT_NODE_GAP / (2 * r)));
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
}
// Детерминированная раскладка первого уровня по концентрическим кругам.
// Сначала максимально заполняем внутренний круг, затем второй, третий и т.д.
// Соседние кольца разнесены минимум на 87px, а каждое следующее чуть повёрнуто,
// чтобы линии второго/третьего круга естественно проходили под аватарками внутренних кругов.
function buildOrbitPlacements(total) {
const count = Math.max(0, Number(total) || 0);
const out = new Array(count);
let start = 0;
let ring = 0;
while (start < count) {
const radius = ORBIT_FIRST_R + ring * ORBIT_GAP;
const capacity = orbitCapacity(radius);
const ringCount = Math.min(capacity, count - start);
const phase = 0.52 + (ring % 2 ? Math.PI / Math.max(6, ringCount) : 0);
for (let i = 0; i < ringCount; i += 1) {
const angle = spreadAngle(i, ringCount, phase);
out[start + i] = { radius, angle, ring };
}
start += ringCount;
ring += 1;
}
return out;
}
function applyOrbitTargets(peers) {
const list = Array.isArray(peers) ? peers : [];
const placements = buildOrbitPlacements(list.length);
list.forEach((n, i) => {
const p = placements[i] || { radius: ORBIT_FIRST_R, angle: spreadAngle(i, list.length), ring: 0 };
n.targetR = p.radius;
n.angle = p.angle;
n.orbitRing = p.ring;
n.tx = Math.cos(n.angle) * n.targetR;
n.ty = Math.sin(n.angle) * n.targetR;
});
}
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
@@ -360,8 +408,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
const specs = [];
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
const tier1Orbit = buildOrbitPlacements(tier1.length);
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] }));
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
return { focusId: fId, specs };
@@ -370,17 +419,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function buildNodes(srcModel) {
const { focusId: fId, specs } = computeSpecs(srcModel);
focusId = fId;
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly));
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly, s.orbit));
}
function makeNodeState(src, isFocus, index, total, dotOnly = false) {
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
const tier = Number(src.tier) || 1;
// органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад)
const jr = (hash01(src.id) - 0.5) * 18;
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
@@ -404,6 +452,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
strength,
targetR,
angle,
orbitRing: orbit?.ring || 0,
tx,
ty,
x: tx * INTRO_FACTOR,
@@ -545,7 +594,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: 'друг', business: 'бизнес', contact: 'контакт' };
const rel = { family: 'семья', friend: 'друг', close_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>`;
}
@@ -567,10 +616,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.strength = strength;
node.relationType = src.relationType;
node.shining = Boolean(src.shining);
const jr = (hash01(src.id) - 0.5) * 18;
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
node.orbitRing = spec.orbit?.ring || 0;
node.tx = Math.cos(node.angle) * node.targetR;
node.ty = Math.sin(node.angle) * node.targetR;
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
@@ -990,17 +1038,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
target.vy = 0;
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
peers.forEach((n, i) => {
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
n.angle = spreadAngle(i, peers.length);
applyOrbitTargets(peers);
peers.forEach((n) => {
n.vx = 0;
n.vy = 0;
const tx = Math.cos(n.angle) * n.targetR;
const ty = Math.sin(n.angle) * n.targetR;
n.tx = tx; // обновляем целевую точку, иначе физика после твина утянет узел назад
n.ty = ty;
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
to.set(n.id, { x: tx, y: ty, scale: sc });
to.set(n.id, { x: n.tx, y: n.ty, scale: sc });
});
tween = {
@@ -1091,11 +1134,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const focus = nodes.find((n) => n.isFocus);
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
visiblePeers.forEach((n, i) => {
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
n.angle = spreadAngle(i, visiblePeers.length);
n.tx = Math.cos(n.angle) * n.targetR;
n.ty = Math.sin(n.angle) * n.targetR;
applyOrbitTargets(visiblePeers);
visiblePeers.forEach((n) => {
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
apply(n, n.tx, n.ty, sc, 1);
});
@@ -1598,7 +1638,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node = oldNode; isNew = false;
} else {
if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly, spec.orbit);
isNew = true;
}
+2 -2
View File
@@ -31,7 +31,7 @@ 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, relationType, point, actions = [] } = {}) {
export function openNodeMenu({ login, displayName = '', relationType, point, actions = [] } = {}) {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return;
@@ -43,7 +43,7 @@ export function openNodeMenu({ login, relationType, point, actions = [] } = {})
<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(login)}</span>
<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-rel">${escapeHtml(relationLabelRu(relationType))}</span>
</div>
${itemsHtml}
+115 -67
View File
@@ -1,14 +1,19 @@
import { renderHeader } from '../components/header.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,
saveProfileToggle,
saveProfileStatus,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
@@ -16,8 +21,17 @@ import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'profile-edit-view', title: 'Редактирование профиля' };
function toggleText(enabled) {
return enabled ? 'Yes' : 'No';
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 showLocalErrorAlert(prefix, error) {
@@ -38,11 +52,9 @@ const GENDER_OPTIONS = Object.freeze([
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
]);
// Родственные типы сохранены в протоколе, но пока намеренно скрыты из UI.
const RELATIVE_RELATION_OPTIONS = Object.freeze([
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
{ value: 'spouse', label: 'Жена / Муж (по полу)' },
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
{ value: 'friend', label: 'Друг' },
{ value: 'close_friend', label: 'Близкий друг' },
]);
@@ -75,6 +87,7 @@ function relationAccusativeLabel(type, targetGender) {
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
return 'жену/мужа';
}
if (type === 'friend') return 'друга';
return 'близкого друга';
}
@@ -120,8 +133,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-toggle="official">Официальный: No</button>
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="shine">Сияющий: No</button>
<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>
`;
const status = document.createElement('div');
@@ -134,22 +147,23 @@ 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 officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]');
const shineBtn = badgesRow.querySelector('[data-status="shine"]');
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
const avatarActionEl = topRow.querySelector('[data-change-avatar="true"]');
let currentFields = [];
let currentToggles = [];
let currentAccountRole = '';
let currentShineStatus = '';
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
@@ -234,50 +248,76 @@ export function render({ navigate, chrome }) {
}));
}
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;
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');
}
if (prefix === 'Официальный') {
button.classList.add('is-yes-official');
} else {
button.classList.add('is-yes-shine');
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');
}
}
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 openFieldEditModal({ label, value, placeholder = '' }) {
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 }) {
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>
<input
${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
id="profile-field-edit-input"
class="input"
type="text"
maxlength="300"
maxlength="${Number(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>
@@ -291,7 +331,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)) {
if (!(modal instanceof HTMLElement) || (!(inputEl instanceof HTMLInputElement) && !(inputEl instanceof HTMLTextAreaElement))) {
root.innerHTML = '';
resolve(null);
return;
@@ -308,7 +348,7 @@ export function render({ navigate, chrome }) {
cancelEl?.addEventListener('click', () => close(null));
saveEl?.addEventListener('click', () => close(inputEl.value));
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
if (!multiline && event.key === 'Enter') {
event.preventDefault();
close(inputEl.value);
}
@@ -567,20 +607,21 @@ export function render({ navigate, chrome }) {
status.className = 'status-line';
status.textContent = 'Загрузка параметров...';
reloadBtn.disabled = true;
officialBtn.disabled = true;
accountRoleBtn.disabled = true;
shineBtn.disabled = true;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
try {
const snapshot = await loadProfileSnapshot(login);
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
currentAccountRole = snapshot.accountRole || '';
currentShineStatus = snapshot.shineStatus || '';
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
syncIdentity();
renderFields(currentFields);
updateTogglesUi();
updateStatusesUi();
updateGenderUi();
updateAvatarUi();
@@ -592,7 +633,7 @@ export function render({ navigate, chrome }) {
showLocalErrorAlert('Ошибка загрузки параметров профиля', error);
} finally {
reloadBtn.disabled = false;
officialBtn.disabled = false;
accountRoleBtn.disabled = false;
shineBtn.disabled = false;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
}
@@ -629,27 +670,32 @@ export function render({ navigate, chrome }) {
}
}
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;
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;
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
try {
await saveProfileToggle(login, toggleKey, nextEnabled);
await saveProfileStatus(login, statusKey, picked);
await refreshProfileSnapshot();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось изменить ${toggleKey}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${toggleKey}`, error);
status.textContent = `Не удалось изменить ${statusKey}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${statusKey}`, error);
}
}
@@ -661,8 +707,14 @@ 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 = 'Сохранение в блокчейн...';
@@ -753,9 +805,6 @@ export function render({ navigate, chrome }) {
status.textContent = 'Сохранение связи...';
try {
if (relationType === 'close_friend') {
await authService.addCloseFriend(targetLogin);
} else {
if (!state.session.storagePwdInMemory) {
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
}
@@ -766,7 +815,6 @@ export function render({ navigate, chrome }) {
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
status.className = 'status-line is-available';
status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`;
} catch (error) {
@@ -791,8 +839,8 @@ export function render({ navigate, chrome }) {
});
reloadBtn.addEventListener('click', refreshProfileSnapshot);
officialBtn.addEventListener('click', () => onToggleClick('official'));
shineBtn.addEventListener('click', () => onToggleClick('shine'));
accountRoleBtn.addEventListener('click', () => onStatusClick('account_role'));
shineBtn.addEventListener('click', () => onStatusClick('shine'));
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
avatarActionEl?.addEventListener('click', () => { void onChangeAvatarClick(); });
+139 -255
View File
@@ -1,27 +1,15 @@
import { profile } from '../mock-data.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 { createDropdownMenu } from '../components/dropdown-menu.js';
import { userDisplayName } from '../services/user-display.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;')
@@ -31,72 +19,57 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
function renderProfileInfoText(text) {
const [intro, details] = String(text || '').split(/\n\n/, 2);
if (!details) {
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`;
}
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 fieldMap(snapshot) {
const out = {};
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
});
return out;
}
function openProfileInfoModal({ title, text }) {
function openTextModal(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 class="modal" id="profile-text-modal">
<div class="modal-card stack">
<h3>${escapeHtml(title)}</h3>
<div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text || 'Не заполнено')}</div>
<button class="secondary-btn" id="profile-text-close">Закрыть</button>
</div>
<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>
`;
</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();
root.querySelector('#profile-text-close')?.addEventListener('click', close);
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-text-modal') close();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
function statusBadges(accountRole, shineStatus) {
const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
const shine = shineStatus === 'shining' ? 'Сияющий' : '';
return `<div class="row wrap-row">
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
</div>`;
}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
function statsRows(stats = {}) {
return [
['friends', 'Друзья', stats.friendsCount],
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
];
}
export function render({ navigate, chrome }) {
const login = state.session.login || profile.login;
const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
@@ -106,15 +79,12 @@ export function render({ navigate, chrome }) {
<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());
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
</div>`;
const menuButton = topbar.querySelector('.profile-head-menu-btn');
menuButton?.append(createOverflowDots());
const profileMenu = createDropdownMenu({
anchorEl: profileMenuButton,
anchorEl: menuButton,
className: 'profile-head-menu',
minWidth: 230,
items: [
@@ -123,200 +93,114 @@ export function render({ navigate, chrome }) {
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
],
});
chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack profile-main-card';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
screen.append(status, body);
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>
`;
let current = null;
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>
`;
const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list';
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"]');
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,
};
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: 'profile-avatar',
}));
}
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');
}
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 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);
});
}
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 = {
function renderProfile() {
if (!current) return;
const { snapshot, user } = current;
const fields = fieldMap(snapshot);
const firstName = fields.first_name || '';
const lastName = fields.last_name || '';
const displayName = userDisplayName({ login, firstName, lastName });
const avatar = snapshot?.avatar?.txId
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
: null;
const stats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingUsersCount: Number(user?.followingUsersCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
friendsCount: Number(user?.friendsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
renderFields(currentFields);
} catch (error) {
// ignore status row in profile-view
}
body.innerHTML = '';
const identity = document.createElement('div');
identity.className = 'card row';
identity.style.gap = '12px';
identity.style.alignItems = 'center';
identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
const identityText = document.createElement('div');
identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
identity.append(identityText);
body.append(identity);
const badges = document.createElement('div');
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
body.append(...badges.children);
if (fields.about) {
const about = document.createElement('div');
about.className = 'card profile-about';
about.style.whiteSpace = 'pre-wrap';
about.textContent = fields.about;
body.append(about);
}
card.append(topRow, badgesRow, listWrap);
screen.append(card);
const statsGrid = document.createElement('div');
statsGrid.className = 'profile-stats-grid';
statsRows(stats).forEach(([kind, label, value]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'card profile-stat-card';
button.dataset.profileList = kind;
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
statsGrid.append(button);
});
body.append(statsGrid);
updateAvatarUi();
refreshProfileSnapshot();
const detailRow = document.createElement('div');
detailRow.className = 'row wrap-row';
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
body.append(detailRow);
}
body.addEventListener('click', (event) => {
if (!current) return;
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
if (!el) return;
const kind = el.dataset.profileList;
if (kind) {
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
return;
}
const fields = fieldMap(current.snapshot);
if (el.dataset.profileDetail === 'contacts') {
openTextModal('Контакты', [
fields.web ? `Links: ${fields.web}` : '',
fields.phone ? `Телефон: ${fields.phone}` : '',
fields.address ? `Адрес: ${fields.address}` : '',
].filter(Boolean).join('\n') || 'Не заполнено');
}
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
});
async function refresh() {
if (state.session.isLocalDemo) {
status.textContent = 'Локальный тестовый режим.';
return;
}
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
current = { snapshot, user };
renderProfile();
status.textContent = '';
}
refresh().catch((error) => {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
});
screen.cleanup = () => profileMenu.destroy();
return screen;
@@ -0,0 +1,67 @@
import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService } from '../state.js';
import { navigateBack } from '../router.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'user-profile-list-view', title: 'Список' };
function parseAvatar(raw) {
const value = String(raw || '');
const m = value.match(/(?:^|,)\s*AR:([A-Za-z0-9_-]{43})(?:,|$)/);
return m ? { ar: m[1] } : null;
}
const TITLES = {
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили основной аккаунт',
primary_given: 'Подтверждённые аккаунты', shine_received: 'Считают сияющим', shine_given: 'Подтверждённые сияющие',
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
};
export function render({ navigate, route }) {
const login = String(route?.params?.login || '').trim();
const kind = String(route?.params?.kind || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack';
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
(async () => {
try {
if (kind === 'channels_owned' || kind === 'channels_following') {
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
const t = document.createElement('div'); t.className = 'profile-list-row-text';
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
el.append(t);
el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`));
body.append(el);
});
status.textContent = rows.length ? '' : 'Список пуст.';
return;
}
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
const rows = Array.isArray(payload?.users) ? payload.users : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
const fullName = userDisplayName(row);
const t = document.createElement('div'); t.className = 'profile-list-row-text';
const marks = [
row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '',
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
].filter(Boolean).join(' · ');
t.innerHTML = `<b>${fullName}</b><small>${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
});
status.textContent = rows.length ? '' : 'Список пуст.';
} catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; }
})();
return screen;
}
+304 -467
View File
@@ -1,16 +1,12 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
buildIdentityLines,
loadRelationsForPair,
loadUserProfileCard,
} from '../services/user-connections.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
export const pageMeta = { id: 'user', title: 'Профиль' };
function escapeHtml(text) {
return String(text || '')
@@ -21,488 +17,329 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
function openProfileInfoModal({ title, text }) {
function effectiveSocial(flags = {}) {
if (flags.outCloseFriend) return 'close_friend';
if (flags.outFriend) return 'friend';
if (flags.outContact) return 'contact';
return 'none';
}
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 openProfileSheet(title, html) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="profile-info-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
<div class="user-profile-sheet-backdrop" id="user-profile-sheet-backdrop">
<section class="user-profile-sheet" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
<div class="user-profile-sheet-handle" aria-hidden="true"></div>
<div class="user-profile-sheet-title">${escapeHtml(title)}</div>
<div class="user-profile-sheet-content">${html}</div>
</section>
</div>`;
const close = () => {
if (root.querySelector('#user-profile-sheet-backdrop')) root.innerHTML = '';
};
root.querySelector('#user-profile-sheet-backdrop')?.addEventListener('click', (event) => {
if (event.target?.id === 'user-profile-sheet-backdrop') close();
});
}
function aboutSheetHtml(card) {
return `<div class="user-profile-sheet-copy">${escapeHtml(card?.about || 'Не заполнено')}</div>`;
}
function contactsSheetHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-sheet-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
function addIconHtml() {
return `
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
<path d="M10.5 20.5 17 27l13-14" />
</svg>`;
}
function relationMenuHtml(flags = {}) {
const current = effectiveSocial(flags);
const rows = [
['friend', 'Друг'],
['close_friend', 'Близкий друг'],
['contact', 'Контакт'],
];
return rows.map(([kind, label]) => `
<button type="button" class="user-profile-add-option${current === kind ? ' is-current' : ''}" data-relation-kind="${kind}">
<span>${escapeHtml(label)}</span>
<span class="user-profile-add-option-check" aria-hidden="true">${current === kind ? '✓' : ''}</span>
</button>`).join('');
}
export function render({ navigate, route, chrome }) {
const requestedLogin = String(route?.params?.login || '').trim();
const selfLogin = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const header = renderHeader({
title: requestedLogin || 'Профиль',
leftAction: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
chrome?.setTopbar(header);
const status = document.createElement('div');
status.className = 'status-line user-profile-status';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'user-profile-body';
screen.append(status, body);
let card = null;
let relationFlags = null;
let relationLoadPromise = null;
let addMenu = null;
let addActionButton = null;
function updateRelationUi() {
if (!addMenu) return;
addMenu.innerHTML = relationMenuHtml(relationFlags || {});
const social = effectiveSocial(relationFlags || {});
addActionButton?.classList.toggle('is-active', social !== 'none');
addActionButton?.setAttribute('aria-label', social === 'none' ? 'Добавить' : 'Изменить связь');
}
async function ensureRelationFlags() {
if (relationFlags) return relationFlags;
if (relationLoadPromise) return relationLoadPromise;
if (!selfLogin || !card?.login) return {};
relationLoadPromise = loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login })
.then((flags) => {
relationFlags = flags || {};
updateRelationUi();
return relationFlags;
})
.finally(() => {
relationLoadPromise = null;
});
return relationLoadPromise;
}
async function setRelationKind(kind, enabled) {
await authService.setUserRelation({
login: selfLogin,
toLogin: card.login,
kind,
enabled,
storagePwd: state.session.storagePwdInMemory,
});
}
async function changeSocial(next) {
const flags = await ensureRelationFlags();
const current = effectiveSocial(flags);
if (current === next) return;
if (next === 'contact') {
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
if (flags.outFriend) await setRelationKind('friend', false);
if (!flags.outContact) await setRelationKind('contact', true);
}
if (next === 'friend') {
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
if (!flags.outFriend) await setRelationKind('friend', true);
}
if (next === 'close_friend' && !flags.outCloseFriend) {
await setRelationKind('close_friend', true);
}
relationFlags = await loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login });
updateRelationUi();
}
function renderProfile() {
if (!card) return;
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const title = header.querySelector('.page-title');
if (title) title.textContent = card.login;
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div>
<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 close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close);
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-info-modal') close();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
}
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">
${addIconHtml()}
</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
</button>
</div>
</div>` : ''}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
<div class="user-profile-detail-links">
<button type="button" data-profile-detail="about">О себе</button>
<button type="button" data-profile-detail="contacts">Контакты</button>
</div>`;
function genderText(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'male') return 'Мужской';
if (normalized === 'female') return 'Женский';
return 'Не указан';
}
function relationButtonLabel(kind, flags) {
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
return flags.outFollow ? 'Отписаться' : 'Подписаться';
}
function relationNextState(kind, flags) {
if (kind === 'contact') return !flags.outContact;
if (kind === 'friend') return !flags.outFriend;
return !flags.outFollow;
}
function relationConfirmLabel(kind) {
if (kind === 'contact') return 'контакт';
if (kind === 'friend') return 'статус близкого друга';
return 'подписку';
}
function relationStateText(kind, flags) {
if (kind === 'contact') {
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
return '';
}
if (kind === 'friend') {
if (flags.outFriend && flags.inFriend) return 'Вы взаимно близкие друзья.';
if (flags.outFriend) return 'Вы считаете этот профиль близким другом.';
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
return '';
}
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inFollow) return 'Этот профиль подписан на вас.';
return '';
}
function opinionItemsFromFlags(flags) {
const items = [];
if (flags.outShineSeen) {
items.push({
kind: 'shine_seen',
text: 'вы утверждаете, что очень мало знаете этого человека, но вы видели его сияющим, и всё, что вы о нём знаете, подтверждает это',
label: 'видел сияющим',
});
}
if (flags.outShineConfirmed) {
items.push({
kind: 'shine_confirmed',
text: 'вы утверждаете, что достаточно хорошо знаете этого человека и точно уверены, что этот человек сияющий',
label: 'точно сияющий',
});
}
if (flags.outKnownPerson) {
items.push({
kind: 'known_person',
text: 'вы утверждаете, что просто знаете этого человека',
label: 'просто знаю',
});
}
return items;
}
function resolveActiveOpinionKind(flags) {
if (flags.outShineSeen) return 'shine_seen';
if (flags.outShineConfirmed) return 'shine_confirmed';
if (flags.outKnownPerson) return 'known_person';
return '';
}
function opinionLabelByKind(kind) {
if (kind === 'shine_seen') return 'мало знаком, но видел сияющим';
if (kind === 'shine_confirmed') return 'точно уверен, что сияющий';
if (kind === 'known_person') return 'просто знаю человека';
return kind;
}
function renderIdentity(card) {
const lines = buildIdentityLines({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
});
const row = document.createElement('div');
row.className = 'row';
row.style.gap = '12px';
row.style.alignItems = 'center';
row.append(renderUserAvatar({
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
avatar: card.avatar,
size: 'xl',
className: 'profile-avatar',
className: 'user-profile-hero-avatar',
glow: shining,
}));
const identityLines = document.createElement('div');
identityLines.className = 'profile-identity-lines';
lines.forEach((line, idx) => {
const lineEl = document.createElement('div');
lineEl.className = `profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}`;
lineEl.textContent = line;
identityLines.append(lineEl);
});
row.append(identityLines);
addMenu = body.querySelector('.user-profile-add-menu');
addActionButton = body.querySelector('[data-profile-action="add"]');
updateRelationUi();
status.textContent = '';
return row;
}
function renderReadOnlyBadges(card) {
return `
<div class="row wrap-row">
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button>
</div>
`;
}
function renderRelations(flags) {
const rows = [
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
];
const opinionItems = opinionItemsFromFlags(flags);
const hasOpinion = opinionItems.length > 0;
return `
<div class="card stack user-relations-list" data-profile-relations="true">
${rows.map((row) => `
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
<span class="user-rel-text">${escapeHtml(row.text)}</span>
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
</div>
`).join('')}
<div class="user-rel-opinions-wrap ${hasOpinion ? '' : 'is-empty'}">
<div class="user-rel-opinions-list">
${opinionItems.map((item) => `
<div class="user-rel-opinion-item">${escapeHtml(item.text)}</div>
`).join('')}
</div>
<div class="user-rel-opinions-hint">Добавьте одну из этих трёх формулировок.</div>
</div>
<div class="user-rel-row">
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
</div>
</div>
`;
}
function openOpinionMenuModal({ flags, onApply }) {
const root = document.getElementById('modal-root');
if (!root) return;
const activeKind = resolveActiveOpinionKind(flags);
const items = [
{ kind: 'known_person', title: 'просто знаю человека' },
{ kind: 'shine_confirmed', title: 'точно уверен, что сияющий' },
{ kind: 'shine_seen', title: 'мало знаком, но видел сияющим' },
];
const rowsHtml = items
.filter((item) => item.kind !== activeKind)
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
.join('');
const removeHtml = activeKind
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
: '';
root.innerHTML = `
<div class="modal" id="user-opinion-modal">
<div class="modal-card stack">
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
<div class="stack">${rowsHtml}${removeHtml}</div>
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#user-opinion-modal-close')?.addEventListener('click', close);
root.querySelector('#user-opinion-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'user-opinion-modal') close();
});
root.querySelectorAll('[data-opinion-mode]').forEach((btn) => {
btn.addEventListener('click', async () => {
const nextKind = String(btn.getAttribute('data-opinion-kind') || '').trim();
const mode = String(btn.getAttribute('data-opinion-mode') || '').trim();
close();
if (!nextKind) return;
await onApply({ mode, nextKind, activeKind });
});
});
}
function renderReadOnlyParams(card) {
const rows = [
{ label: 'Имя', value: card.firstName },
{ label: 'Фамилия', value: card.lastName },
{ label: 'Пол', value: genderText(card.gender) },
{ label: 'Адрес', value: card.address },
{ label: 'Web', value: card.web },
{ label: 'Телефон', value: card.phone },
];
return `
<div class="card stack profile-param-list">
${rows.map((row) => `
<div class="card profile-param-item row">
<div class="profile-param-value"><b>${row.label}</b>: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}</div>
</div>
`).join('')}
</div>
`;
}
export function render({ navigate, route }) {
const requestedLogin = String(route.params.login || '').trim();
const sessionLogin = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
screen.append(
renderHeader({
title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigateBack() },
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
}),
status,
body,
);
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
linksHeaderBtn?.classList.add('profile-links-header-btn');
let currentCard = null;
let currentFlags = null;
let isBusy = false;
function syncActionButtons() {
const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]');
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
followBtn.textContent = relationButtonLabel('follow', currentFlags);
contactBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
followBtn.disabled = Boolean(isSelf);
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
opinionBtn.disabled = Boolean(isSelf);
if (!isSelf && selfLogin) {
void ensureRelationFlags().catch(() => {});
}
}
body.addEventListener('click', async (event) => {
if (!card) return;
const listButton = event.target.closest('[data-profile-list]');
if (listButton) {
const kind = listButton.dataset.profileList;
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (detailButton?.dataset.profileDetail === 'about') {
openProfileSheet('О себе', aboutSheetHtml(card));
return;
}
if (detailButton?.dataset.profileDetail === 'contacts') {
openProfileSheet('Контакты', contactsSheetHtml(card));
return;
}
const relationButton = event.target.closest('[data-relation-kind]');
if (relationButton) {
if (!selfLogin) {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = 'Для добавления пользователя необходимо войти.';
return;
}
const next = relationButton.dataset.relationKind;
try {
addMenu?.classList.add('is-busy');
await changeSocial(next);
if (addMenu) addMenu.hidden = true;
addActionButton?.setAttribute('aria-expanded', 'false');
status.className = 'status-line user-profile-status';
status.textContent = '';
} catch (error) {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'Не удалось изменить связь'}`;
} finally {
addMenu?.classList.remove('is-busy');
}
return;
}
const actionButton = event.target.closest('[data-profile-action]');
const action = actionButton?.dataset.profileAction;
if (action === 'add') {
if (!addMenu) return;
navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
return;
}
if (action === 'links') {
navigate(makeProfileLinksRoute(card.login));
return;
}
if (action === 'chat') {
navigate(`chat/${encodeURIComponent(card.login)}`);
}
});
const handleOutsidePointer = (event) => {
if (!addMenu || addMenu.hidden) return;
if (event.target instanceof Node && body.contains(event.target)) {
const insideActions = event.target.closest?.('.user-profile-actions-wrap');
if (insideActions) return;
}
addMenu.hidden = true;
addActionButton?.setAttribute('aria-expanded', 'false');
};
document.addEventListener('pointerdown', handleOutsidePointer);
async function refresh() {
if (!requestedLogin) {
status.className = 'status-line is-unavailable';
status.textContent = 'Не передан login пользователя.';
return;
card = await loadUserProfileCard(requestedLogin);
renderProfile();
}
isBusy = true;
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
try {
const card = await loadUserProfileCard(requestedLogin);
const flags = await loadRelationsForPair({
currentLogin: sessionLogin,
targetLogin: card.login,
refresh().catch((error) => {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
});
currentCard = card;
currentFlags = flags;
screen.cleanup = () => {
document.removeEventListener('pointerdown', handleOutsidePointer);
const root = document.getElementById('modal-root');
if (root?.querySelector('#user-profile-sheet-backdrop')) root.innerHTML = '';
};
body.innerHTML = `
${renderReadOnlyBadges(card)}
${renderRelations(flags)}
${renderReadOnlyParams(card)}
`;
const identityCard = document.createElement('div');
identityCard.className = 'card stack';
identityCard.append(renderIdentity(card));
body.prepend(identityCard);
syncActionButtons();
if (String(route?.params?.section || '').toLowerCase() === 'links') {
const rel = body.querySelector('[data-profile-relations="true"]');
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
status.className = 'status-line is-available';
status.textContent = 'Профиль обновлён.';
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
} finally {
isBusy = false;
}
}
async function onRelationAction(kind) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
if (kind === 'opinion-menu') {
openOpinionMenuModal({
flags: currentFlags,
onApply: onOpinionApply,
});
return;
}
const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
'Будет отправлен AddBlock CONNECTION.',
);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind,
enabled: nextEnabled,
storagePwd: state.session.storagePwdInMemory,
});
await refresh();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
async function onOpinionApply({ mode, nextKind, activeKind }) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const confirmed = window.confirm(`Изменить мнение о пользователе ${currentCard.login}?`);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
if (activeKind) {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: activeKind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
if (mode === 'set') {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: nextKind,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
await refresh();
if (mode === 'set') {
const opinionVisible = Boolean(
currentFlags?.outKnownPerson
|| currentFlags?.outShineConfirmed
|| currentFlags?.outShineSeen,
);
if (!opinionVisible) {
await new Promise((resolve) => window.setTimeout(resolve, 350));
await refresh();
}
}
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const infoBtn = target.closest('[data-profile-info]');
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
if (infoKind === 'official') {
openProfileInfoModal({
title: 'Официальный канал',
text: officialInfoText(),
});
return;
}
if (infoKind === 'shine') {
openProfileInfoModal({
title: 'Справка о сияющих',
text: shineInfoText(),
});
return;
}
const actionBtn = target.closest('[data-relation-action]');
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
if (!kind) return;
void onRelationAction(kind);
});
refresh();
return screen;
}
@@ -0,0 +1,51 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { navigateBack } from '../router.js';
export const pageMeta = { id: 'user-relation-manage-view', title: 'Добавить' };
function effectiveSocial(f) { if (f.outCloseFriend) return 'close_friend'; if (f.outFriend) return 'friend'; if (f.outContact) return 'contact'; return 'none'; }
export function render({ route }) {
const targetLogin = String(route?.params?.login || '').trim(); const selfLogin = String(state.session.login || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack'; const status = document.createElement('div'); status.className = 'status-line';
screen.append(renderHeader({ title: 'Добавить', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
let flags, selfCard, targetCard;
async function setKind(kind, enabled) { await authService.setUserRelation({ login: selfLogin, toLogin: targetLogin, kind, enabled, storagePwd: state.session.storagePwdInMemory }); }
async function changeSocial(next) {
const current = effectiveSocial(flags); if (current === next) return;
if (next === 'none') { if (flags.outCloseFriend) await setKind('close_friend', false); if (flags.outFriend) await setKind('friend', false); if (flags.outContact) await setKind('contact', false); }
if (next === 'contact') { if (flags.outCloseFriend) await setKind('close_friend', false); if (flags.outFriend) await setKind('friend', false); if (!flags.outContact) await setKind('contact', true); }
if (next === 'friend') { if (flags.outCloseFriend) await setKind('close_friend', false); if (!flags.outFriend) await setKind('friend', true); }
if (next === 'close_friend' && !flags.outCloseFriend) await setKind('close_friend', true);
await refresh();
}
async function refresh() {
status.textContent = 'Загрузка...'; body.innerHTML = '';
[selfCard, targetCard, flags] = await Promise.all([loadUserProfileCard(selfLogin), loadUserProfileCard(targetLogin), loadRelationsForPair({ currentLogin: selfLogin, targetLogin })]);
const social = effectiveSocial(flags);
const canPrimary = selfCard.accountRole === 'primary' && targetCard.accountRole !== 'non_voting';
const canShine = selfCard.accountRole === 'primary' && selfCard.shineStatus === 'shining' && targetCard.shineStatus !== 'not_interested';
body.innerHTML = `
<div class="card stack"><b>Связь с ${targetLogin}</b>
<div class="row wrap-row" data-social>${['none','contact','friend','close_friend'].map(v => `<button class="secondary-btn ${social===v?'is-active':''}" data-social-kind="${v}">${({none:'Нет',contact:'Контакт',friend:'Друг',close_friend:'Близкий друг'})[v]}</button>`).join('')}</div>
</div>
${canPrimary ? `<div class="card row"><span>Основной аккаунт</span><button class="secondary-btn" data-confirm="official_account">${flags.outOfficialAccount ? 'Убрать подтверждение' : 'Подтвердить'}</button></div>` : ''}
${canShine ? `<div class="card row"><span>Сияющий</span><button class="secondary-btn" data-confirm="shine_confirmed">${flags.outShineConfirmed ? 'Убрать подтверждение' : 'Подтвердить'}</button></div>` : ''}
`;
status.textContent = '';
}
body.addEventListener('click', async (e) => {
const btn = e.target.closest('button'); if (!btn) return;
const social = btn.dataset.socialKind; const confirm = btn.dataset.confirm;
try { status.textContent = 'Сохранение...'; if (social) await changeSocial(social); else if (confirm) { const enabled = confirm === 'official_account' ? !flags.outOfficialAccount : !flags.outShineConfirmed; await setKind(confirm, enabled); await refresh(); } }
catch (err) { status.className='status-line is-unavailable'; status.textContent=`Ошибка: ${err.message || 'unknown'}`; }
});
refresh().catch(e => { status.className='status-line is-unavailable'; status.textContent=`Ошибка: ${e.message || 'unknown'}`; });
return screen;
}
+6
View File
@@ -107,6 +107,12 @@ export function parseRouteFromPath(pathname = '') {
if (section === 'links') {
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
}
if (section === 'manage') {
return { pageId: 'user-relation-manage-view', params: { login: shineLogin } };
}
if (section === 'list') {
return { pageId: 'user-profile-list-view', params: { login: shineLogin, kind: decodePart(segments[baseOffset + 1] || '') } };
}
if (section === 'channels') {
const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
if (sub === 'owned') {
+18 -3
View File
@@ -90,18 +90,21 @@ const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
close_friend: { on: 10, off: 11 },
friend: { on: 10, off: 11 },
friend: { on: 14, off: 15 },
contact: { on: 20, off: 21 },
// Reserved: пока не используется UI.
follow: { on: 30, off: 31 },
// Reserved: семейные связи пока скрыты из UI.
spouse: { on: 40, off: 41 },
parent: { on: 50, off: 51 },
child: { on: 52, off: 53 },
sibling: { on: 54, off: 55 },
known_person: { on: 60, off: 61 },
// Legacy 60/61 intentionally absent: старые блоки сервер принимает, новый UI их не создаёт.
shine_confirmed: { on: 70, off: 71 },
// Reserved: пока не используется UI.
shine_seen: { on: 74, off: 75 },
official_account: { on: 80, off: 81 },
});
function normalizeServerUrl(url) {
@@ -2902,6 +2905,18 @@ export class AuthService {
return response.payload || {};
}
async listUserProfileRelations(login, listType, limit = 100, offset = 0) {
const response = await this.ws.request('ListUserProfileRelations', { login, listType, limit, offset });
if (response.status !== 200) throw opError('ListUserProfileRelations', response);
return response.payload || {};
}
async listUserProfileChannels(login, mode, limit = 100, offset = 0) {
const response = await this.ws.request('ListUserProfileChannels', { login, mode, limit, offset });
if (response.status !== 200) throw opError('ListUserProfileChannels', response);
return response.payload || {};
}
async searchUsers(prefix, options = {}) {
const payload = { prefix };
if (typeof options?.isServer === 'boolean') {
+35 -2
View File
@@ -50,6 +50,8 @@ function readArray(payload, key) {
const aliases = {
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_known_persons'],
outOfficialAccounts: ['outOfficialAccounts', 'outOfficialAccount', 'out_official_accounts'],
inOfficialAccounts: ['inOfficialAccounts', 'inOfficialAccount', 'in_official_accounts'],
outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
outShineSeen: ['outShineSeen', 'out_shine_seen'],
@@ -81,6 +83,8 @@ async function buildRelationsModel(login) {
return {
outFriends: [],
inFriends: [],
outCloseFriends: [],
inCloseFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
@@ -93,6 +97,8 @@ async function buildRelationsModel(login) {
inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outOfficialAccounts: [],
inOfficialAccounts: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
@@ -129,6 +135,8 @@ async function buildRelationsModel(login) {
return {
outFriends: readArray(graph, 'outFriends') || [],
inFriends: readArray(graph, 'inFriends') || [],
outCloseFriends: readArray(graph, 'outCloseFriends') || [],
inCloseFriends: readArray(graph, 'inCloseFriends') || [],
outContacts,
inContacts: readArray(graph, 'inContacts') || [],
outFollows,
@@ -141,6 +149,8 @@ async function buildRelationsModel(login) {
inSiblings: readArray(graph, 'inSiblings') || [],
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
outOfficialAccounts: readArray(graph, 'outOfficialAccounts') || [],
inOfficialAccounts: readArray(graph, 'inOfficialAccounts') || [],
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
outShineSeen: readArray(graph, 'outShineSeen') || [],
@@ -169,6 +179,8 @@ export async function loadCurrentRelations() {
return {
outFriends: [],
inFriends: [],
outCloseFriends: [],
inCloseFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
@@ -181,6 +193,8 @@ export async function loadCurrentRelations() {
inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outOfficialAccounts: [],
inOfficialAccounts: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
@@ -194,6 +208,8 @@ export function relationFlagsForTarget(relations, targetLogin) {
return {
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
outCloseFriend: listContainsLogin(relations?.outCloseFriends, targetLogin),
inCloseFriend: listContainsLogin(relations?.inCloseFriends, targetLogin),
outContact: listContainsLogin(relations?.outContacts, targetLogin),
inContact: listContainsLogin(relations?.inContacts, targetLogin),
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
@@ -206,6 +222,8 @@ export function relationFlagsForTarget(relations, targetLogin) {
inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
outOfficialAccount: listContainsLogin(relations?.outOfficialAccounts, targetLogin),
inOfficialAccount: listContainsLogin(relations?.inOfficialAccounts, targetLogin),
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
@@ -236,9 +254,24 @@ export async function loadUserProfileCard(login) {
address: fields.address || '',
web: fields.web || '',
phone: fields.phone || '',
about: fields.about || '',
spiritualPath: fields.spiritual_path || '',
accountRole: String(snapshot?.accountRole || '').trim().toLowerCase(),
shineStatus: String(snapshot?.shineStatus || '').trim().toLowerCase(),
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
official: Boolean(toggles.official),
shine: Boolean(toggles.shine),
// Compatibility aliases for older graph/card consumers; values come only from the new profile schema.
official: String(snapshot?.accountRole || '').trim().toLowerCase() === 'primary',
shine: String(snapshot?.shineStatus || '').trim().toLowerCase() === 'shining',
stats: {
ownedPublicChannelsCount: Number(user.ownedPublicChannelsCount || 0),
followingChannelsCount: Number(user.followingChannelsCount || 0),
friendsCount: Number(user.friendsCount || 0),
closeFriendsCount: Number(user.closeFriendsCount || 0),
primaryReceivedCount: Number(user.primaryConfirmationsReceivedCount || 0),
primaryGivenCount: Number(user.primaryConfirmationsGivenCount || 0),
shineReceivedCount: Number(user.shineConfirmationsReceivedCount || 0),
shineGivenCount: Number(user.shineConfirmationsGivenCount || 0),
},
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId).trim(),
+6
View File
@@ -0,0 +1,6 @@
export function userDisplayName({ login = '', firstName = '', lastName = '' } = {}) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
const full = [first, last].filter(Boolean).join(' ').trim();
return full || String(login || '').trim() || 'unknown';
}
+51 -24
View File
@@ -10,14 +10,19 @@ export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
{ key: 'web', readKeys: ['web'], label: 'Веб', placeholder: 'Сайт или профиль' },
{ key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' },
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
];
export const profileToggleDefs = [
{ key: 'official', label: 'Официальный' },
{ key: 'shine', label: 'Сияющий' },
];
export const profileToggleDefs = []; // legacy boolean toggles are no longer used by the new UI.
export const PROFILE_ACCOUNT_ROLE_PRIMARY = 'primary';
export const PROFILE_ACCOUNT_ROLE_NON_VOTING = 'non_voting';
export const PROFILE_SHINE_SHINING = 'shining';
export const PROFILE_SHINE_UNKNOWN = 'unknown';
export const PROFILE_SHINE_NOT_INTERESTED = 'not_interested';
export const PROFILE_GENDER_MALE = 'male';
export const PROFILE_GENDER_FEMALE = 'female';
@@ -104,23 +109,31 @@ export async function loadProfileSnapshot(login) {
key: field.key,
label: field.label,
placeholder: field.placeholder,
maxLength: Number(field.maxLength || 300),
multiline: Boolean(field.multiline),
value: latest?.value || '',
timeMs: latest?.timeMs || 0,
});
}
const toggles = [];
for (let i = 0; i < profileToggleDefs.length; i += 1) {
const toggle = profileToggleDefs[i];
const latest = loadLatestByAliasesFromItems(items, [toggle.key]);
toggles.push({
key: toggle.key,
label: toggle.label,
enabled: latest ? parseToggleValue(latest.value) : false,
rawValue: latest?.value || 'no',
timeMs: latest?.timeMs || 0,
});
}
const latestAccountRole = loadLatestByAliasesFromItems(items, ['account_role']);
const rawAccountRole = String(latestAccountRole?.value || '').trim().toLowerCase();
const accountRole = rawAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY || rawAccountRole === PROFILE_ACCOUNT_ROLE_NON_VOTING
? rawAccountRole
: '';
const latestShine = loadLatestByAliasesFromItems(items, ['shine']);
const rawShine = String(latestShine?.value || '').trim().toLowerCase();
let shineStatus = '';
if (rawShine === PROFILE_SHINE_SHINING || rawShine === 'yes') shineStatus = PROFILE_SHINE_SHINING;
else if (rawShine === PROFILE_SHINE_UNKNOWN) shineStatus = PROFILE_SHINE_UNKNOWN;
else if (rawShine === PROFILE_SHINE_NOT_INTERESTED) shineStatus = PROFILE_SHINE_NOT_INTERESTED;
// Legacy shine=no is intentionally ignored. Legacy official=yes/no is not read at all.
const toggles = [
{ key: 'account_role', label: 'Роль аккаунта', enabled: accountRole === PROFILE_ACCOUNT_ROLE_PRIMARY, rawValue: accountRole, timeMs: latestAccountRole?.timeMs || 0 },
{ key: 'shine', label: 'Сияние', enabled: shineStatus === PROFILE_SHINE_SHINING, rawValue: shineStatus, timeMs: latestShine?.timeMs || 0 },
];
const latestGender = loadLatestByAliasesFromItems(items, ['gender']);
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
@@ -145,6 +158,10 @@ export async function loadProfileSnapshot(login) {
return {
fields,
toggles,
accountRole,
accountRoleTimeMs: latestAccountRole?.timeMs || 0,
shineStatus,
shineTimeMs: latestShine?.timeMs || 0,
gender,
genderTimeMs: latestGender?.timeMs || 0,
avatar,
@@ -161,14 +178,24 @@ export async function saveProfileParamBlock(login, key, value) {
});
}
export async function saveProfileToggle(login, key, enabled) {
export async function saveProfileStatus(login, key, value) {
const cleanKey = String(key || '').trim();
const cleanValue = String(value || '').trim().toLowerCase();
if (cleanKey === 'account_role' && ![PROFILE_ACCOUNT_ROLE_PRIMARY, PROFILE_ACCOUNT_ROLE_NON_VOTING].includes(cleanValue)) {
throw new Error('Некорректное значение account_role');
}
if (cleanKey === 'shine' && ![PROFILE_SHINE_SHINING, PROFILE_SHINE_UNKNOWN, PROFILE_SHINE_NOT_INTERESTED].includes(cleanValue)) {
throw new Error('Некорректное значение shine');
}
const storagePwd = await getStoragePwd();
await authService.addBlockUserParam({
login,
param: key,
value: enabled ? 'yes' : 'no',
storagePwd,
});
await authService.addBlockUserParam({ login, param: cleanKey, value: cleanValue, storagePwd });
}
// Kept only for old callers outside the current UI. New code must use saveProfileStatus().
export async function saveProfileToggle(login, key, enabled) {
if (key === 'shine') return saveProfileStatus(login, 'shine', enabled ? PROFILE_SHINE_SHINING : PROFILE_SHINE_UNKNOWN);
if (key === 'official') throw new Error('Параметр official устарел. Используйте account_role.');
throw new Error(`Неизвестный legacy toggle: ${key}`);
}
export async function saveProfileGender(login, gender) {
+1 -1
View File
@@ -24,7 +24,7 @@
.badge-line { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 11px; color: var(--text-muted); background: #0d0d0d; }
.badge.ok { color: #7dcc7d; border-color: #2a4a2a; background: #1a2e1a; }
.badge.warn { color: #ffd37a; border-color: #5f4b22; background: #2f2614; }
.badge.warn { color: #ffd37a; border-color: transparent; background: #2f2614; }
.badge.err { color: #f08080; border-color: #5a2a2a; background: #2e1a1a; }
.details-wrap { margin-top: 12px; }
.details-wrap details { border: 1px solid var(--border); border-radius: var(--radius); background: #121212; }
+1 -1
View File
@@ -38,7 +38,7 @@
.expected-row:last-child { margin-bottom:0; }
.expected-lbl { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
.expected-val { font-family:monospace; font-size:11px; word-break:break-all; }
.gen-msg.warn { display:block; background:#2f2614; border:1px solid #5f4b22; color:#ffd37a; }
.gen-msg.warn { display:block; background:#2f2614; border: 1px solid transparent; color:#ffd37a; }
</style>
</head>
<body>
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -34,8 +34,8 @@ body::before {
--toolbar-height: 78px;
--keyboard-offset: 0px;
background: transparent;
border-left: 1px solid rgba(211, 170, 86, 0.2);
border-right: 1px solid rgba(211, 170, 86, 0.2);
border-left: 1px solid transparent;
border-right: 1px solid transparent;
box-shadow: var(--shadow);
overflow: hidden;
}
@@ -178,7 +178,7 @@ body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
}
.connection-retry-banner.is-connecting {
border-color: rgba(238, 196, 107, 0.42);
border-color: transparent;
color: #ffe8bb;
}
+1 -74
View File
@@ -531,79 +531,6 @@
background: transparent;
}
/* Нижний сниппет (bottom sheet) */
.fg-sheet {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 13;
display: none;
padding: 12px 14px 14px;
background: rgba(16, 24, 40, 0.95);
border: 1px solid rgba(166, 196, 245, 0.26);
border-radius: 16px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.fg-sheet.is-open {
display: block;
animation: fg-sheet-in 200ms ease;
}
@keyframes fg-sheet-in {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.fg-sheet-close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: 0;
color: #9fb6e0;
font-size: 16px;
cursor: pointer;
line-height: 1;
}
.fg-sheet-title {
font-weight: 700;
font-size: 15px;
color: #eaf1ff;
display: flex;
gap: 8px;
align-items: center;
}
.fg-sheet-badge {
font-size: 10px;
background: rgba(130, 235, 255, 0.2);
color: #bff0ff;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.fg-sheet-rel {
font-size: 12px;
color: #9fb6e0;
margin-top: 3px;
}
.fg-sheet-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.fg-sheet-actions > button {
flex: 1;
}
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
@@ -722,6 +649,6 @@
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */
.fg-node.is-common .node-dot {
border-color: rgba(255, 214, 120, 0.95);
border-color: transparent;
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
}