SHA256
Compare commits
40
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a | ||
|
|
0c5089fa79 | ||
|
|
e5556f3706 | ||
|
|
09c7f8541e | ||
|
|
8eb2ef438c | ||
|
|
34abe14151 | ||
|
|
90e37c198f | ||
|
|
1373283947 | ||
|
|
065616a18b | ||
|
|
53cd55a2a0 | ||
|
|
079be37fff | ||
|
|
806a8c57d4 | ||
|
|
797e769cc3 | ||
|
|
822a72e246 | ||
|
|
58b564b385 | ||
|
|
7aa9e2f6a4 | ||
|
|
f645686d87 | ||
|
|
6fcaa5d3d4 | ||
|
|
5d19e17a0b | ||
|
|
52a9f1ac1d | ||
|
|
3dec5e3225 | ||
|
|
90ded1612a | ||
|
|
7ab9fb7c28 | ||
|
|
6f499c68e2 | ||
|
|
19beba8fd7 | ||
|
|
f7e33802c8 | ||
|
|
c03d94105c | ||
|
|
632d56737b | ||
|
|
8801b93973 | ||
|
|
390a2b988e | ||
|
|
a66d7a64e6 | ||
|
|
a8596d3350 | ||
|
|
8a5dabee95 | ||
|
|
da859468d3 | ||
|
|
78ddb8ffb6 | ||
|
|
db37f35d09 | ||
|
|
d8f5fe4fd0 |
@@ -102,16 +102,23 @@ public final class MsgSubType {
|
|||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
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_CONTACT = 20;
|
||||||
/** Удалить из контактов. */
|
/** Удалить из контактов. */
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
|
|
||||||
|
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
|
||||||
/** Подписаться (follow). */
|
/** Подписаться (follow). */
|
||||||
public static final short CONNECTION_FOLLOW = 30;
|
public static final short CONNECTION_FOLLOW = 30;
|
||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
public static final short CONNECTION_UNFOLLOW = 31;
|
||||||
|
|
||||||
|
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||||
/** Добавить связь "жена/муж". */
|
/** Добавить связь "жена/муж". */
|
||||||
public static final short CONNECTION_SPOUSE = 40;
|
public static final short CONNECTION_SPOUSE = 40;
|
||||||
/** Удалить связь "жена/муж". */
|
/** Удалить связь "жена/муж". */
|
||||||
@@ -132,9 +139,8 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
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_KNOWN_PERSON = 60;
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
/** Точно уверен, что сияющий. */
|
||||||
@@ -142,11 +148,17 @@ public final class MsgSubType {
|
|||||||
/** Не подтверждаю, что сияющий. */
|
/** Не подтверждаю, что сияющий. */
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
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_SEEN = 74;
|
||||||
/** Не отмечаю, что видел сияющим. */
|
/** Не отмечаю, что видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
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) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
+9
-3
@@ -20,9 +20,11 @@ import java.util.Objects;
|
|||||||
* PARENT=50, UNPARENT=51
|
* PARENT=50, UNPARENT=51
|
||||||
* CHILD=52, UNCHILD=53
|
* CHILD=52, UNCHILD=53
|
||||||
* SIBLING=54, UNSIBLING=55
|
* 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_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 НЕ ХРАНИМ):
|
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
@@ -185,6 +187,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_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_CONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 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_CONFIRMED & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 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
|
@Override
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_14 = 14;
|
public static final int SCHEMA_VERSION_14 = 14;
|
||||||
public static final int SCHEMA_VERSION_15 = 15;
|
public static final int SCHEMA_VERSION_15 = 15;
|
||||||
public static final int SCHEMA_VERSION_16 = 16;
|
public static final int SCHEMA_VERSION_16 = 16;
|
||||||
|
public static final int SCHEMA_VERSION_17 = 17;
|
||||||
|
public static final int SCHEMA_VERSION_18 = 18;
|
||||||
|
public static final int SCHEMA_VERSION_19 = 19;
|
||||||
|
public static final int SCHEMA_VERSION_20 = 20;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -49,6 +53,10 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
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_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -70,12 +78,17 @@ public final class DatabaseInitializer {
|
|||||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
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_CONTACT = 20;
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
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_FOLLOW = 30;
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
public static final short CONNECTION_UNFOLLOW = 31;
|
||||||
|
|
||||||
|
// Родственные связи пока не используются в UI. DO NOT REMOVE.
|
||||||
public static final short CONNECTION_SPOUSE = 40;
|
public static final short CONNECTION_SPOUSE = 40;
|
||||||
public static final short CONNECTION_UNSPOUSE = 41;
|
public static final short CONNECTION_UNSPOUSE = 41;
|
||||||
|
|
||||||
@@ -88,15 +101,20 @@ public final class DatabaseInitializer {
|
|||||||
public static final short CONNECTION_SIBLING = 54;
|
public static final short CONNECTION_SIBLING = 54;
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
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_KNOWN_PERSON = 60;
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||||
|
|
||||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
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_SEEN = 74;
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
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,
|
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
|
||||||
String user,
|
String user,
|
||||||
String password) throws SQLException {
|
String password) throws SQLException {
|
||||||
@@ -172,6 +190,22 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_16;
|
currentVersion = SCHEMA_VERSION_16;
|
||||||
}
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_17) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_17;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_18) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_18;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_19) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_19;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_20) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
* Совпадает с ConnectionBody:
|
* Совпадает с ConnectionBody:
|
||||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
|
||||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
@@ -70,18 +70,26 @@ public final class MsgSubType {
|
|||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
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_CONTACT = 20;
|
||||||
|
|
||||||
/** Удалить из контактов. */
|
/** Удалить из контактов. */
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
|
|
||||||
|
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
|
||||||
/** Подписаться (follow). */
|
/** Подписаться (follow). */
|
||||||
public static final short CONNECTION_FOLLOW = 30;
|
public static final short CONNECTION_FOLLOW = 30;
|
||||||
|
|
||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
public static final short CONNECTION_UNFOLLOW = 31;
|
||||||
|
|
||||||
|
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||||
/** Добавить связь "жена/муж". */
|
/** Добавить связь "жена/муж". */
|
||||||
public static final short CONNECTION_SPOUSE = 40;
|
public static final short CONNECTION_SPOUSE = 40;
|
||||||
|
|
||||||
@@ -106,10 +114,8 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
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_KNOWN_PERSON = 60;
|
||||||
|
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
/** Точно уверен, что сияющий. */
|
||||||
@@ -118,12 +124,19 @@ public final class MsgSubType {
|
|||||||
/** Не подтверждаю, что сияющий. */
|
/** Не подтверждаю, что сияющий. */
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
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_SEEN = 74;
|
||||||
|
|
||||||
/** Не отмечаю, что видел сияющим. */
|
/** Не отмечаю, что видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
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) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
+19
@@ -426,6 +426,25 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
ps.executeUpdate();
|
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("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
INSERT INTO channel_stats_state (
|
INSERT INTO channel_stats_state (
|
||||||
owner_bch_name,
|
owner_bch_name,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public final class DmDialogStateDAO {
|
|||||||
|
|
||||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
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);
|
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
@@ -76,16 +77,18 @@ public final class DmDialogStateDAO {
|
|||||||
ps.setString(1, cleanOwner);
|
ps.setString(1, cleanOwner);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||||
|
int unreadCount = rs.getInt("unread_count");
|
||||||
DialogSummary row = new DialogSummary(
|
DialogSummary row = new DialogSummary(
|
||||||
rs.getString("owner_login"),
|
rs.getString("owner_login"),
|
||||||
rs.getString("peer_login"),
|
rs.getString("peer_login"),
|
||||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||||
rs.getString("last_message_blob_b64"),
|
rs.getString("last_message_blob_b64"),
|
||||||
rs.getLong("last_message_time_ms"),
|
lastMessageTimeMs,
|
||||||
rs.getInt("unread_count"),
|
unreadCount,
|
||||||
rs.getLong("last_read_receipt_time_ms"),
|
rs.getLong("last_read_receipt_time_ms"),
|
||||||
rs.getLong("updated_at_ms"),
|
rs.getLong("updated_at_ms"),
|
||||||
true
|
lastMessageTimeMs > 0 || unreadCount > 0
|
||||||
);
|
);
|
||||||
byPeer.put(normKey(row.peerLogin()), row);
|
byPeer.put(normKey(row.peerLogin()), row);
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,9 @@ public final class DmDialogStateDAO {
|
|||||||
for (String peer : contacts) {
|
for (String peer : contacts) {
|
||||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||||
}
|
}
|
||||||
|
for (String peer : friends) {
|
||||||
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
|
||||||
|
}
|
||||||
for (String peer : closeFriends) {
|
for (String peer : closeFriends) {
|
||||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||||
}
|
}
|
||||||
@@ -286,6 +292,9 @@ public final class DmDialogStateDAO {
|
|||||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||||
return "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)) {
|
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||||
return "contact";
|
return "contact";
|
||||||
}
|
}
|
||||||
@@ -357,6 +366,8 @@ public final class DmDialogStateDAO {
|
|||||||
String nextRelation = current.relationFlag();
|
String nextRelation = current.relationFlag();
|
||||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "close_friend";
|
nextRelation = "close_friend";
|
||||||
|
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "friend";
|
||||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "contact";
|
nextRelation = "contact";
|
||||||
} else {
|
} else {
|
||||||
@@ -433,7 +444,7 @@ public final class DmDialogStateDAO {
|
|||||||
|
|
||||||
private String normalizeRelationFlag(String value) {
|
private String normalizeRelationFlag(String value) {
|
||||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
public final class UserNotificationSeenStateDAO {
|
||||||
|
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||||
|
private UserNotificationSeenStateDAO() {}
|
||||||
|
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||||
|
|
||||||
|
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||||
|
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||||
|
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||||
|
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||||
|
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return getSeenAt(c, login, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||||
|
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||||
|
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||||
|
FROM user_notifications_state
|
||||||
|
WHERE owner_login = ? AND notification_kind = ?
|
||||||
|
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
ps.setLong(4, Math.max(0, cutoffMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||||
|
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserNotificationEntry e = new UserNotificationEntry();
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
e.setOwnerLogin(rs.getString("owner_login"));
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
|
|||||||
WHERE users_params.time_ms < excluded.time_ms
|
WHERE users_params.time_ms < excluded.time_ms
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
int changed;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, e.getLogin());
|
ps.setString(1, e.getLogin());
|
||||||
ps.setString(2, e.getParam());
|
ps.setString(2, e.getParam());
|
||||||
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
|
|||||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||||
else ps.setNull(6, Types.VARCHAR);
|
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 {
|
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 --------------------
|
// -------------------- SELECT --------------------
|
||||||
|
|
||||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
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 cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
|
""";
|
||||||
|
} else if ("following".equals(mode)) {
|
||||||
|
sql="""
|
||||||
|
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||||
|
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
|
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
||||||
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
|
""";
|
||||||
|
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||||
|
List<ChannelCard> out=new ArrayList<>();
|
||||||
|
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
|
||||||
|
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
|
||||||
|
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
|
||||||
|
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
|
||||||
|
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Notifications v2: three categories + signed seen watermarks.
|
||||||
|
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||||
|
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
source_login TEXT NOT NULL,
|
source_login TEXT NOT NULL,
|
||||||
source_bch_name TEXT NOT NULL,
|
source_bch_name TEXT NOT NULL,
|
||||||
@@ -1113,7 +1113,7 @@ BEGIN
|
|||||||
|
|
||||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
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
|
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END IF;
|
END IF;
|
||||||
@@ -1288,6 +1288,7 @@ BEGIN
|
|||||||
|
|
||||||
positive_rel_type := CASE NEW.msg_sub_type
|
positive_rel_type := CASE NEW.msg_sub_type
|
||||||
WHEN 11 THEN 10
|
WHEN 11 THEN 10
|
||||||
|
WHEN 15 THEN 14
|
||||||
WHEN 21 THEN 20
|
WHEN 21 THEN 20
|
||||||
WHEN 31 THEN 30
|
WHEN 31 THEN 30
|
||||||
WHEN 41 THEN 40
|
WHEN 41 THEN 40
|
||||||
@@ -1297,6 +1298,7 @@ BEGIN
|
|||||||
WHEN 61 THEN 60
|
WHEN 61 THEN 60
|
||||||
WHEN 71 THEN 70
|
WHEN 71 THEN 70
|
||||||
WHEN 75 THEN 74
|
WHEN 75 THEN 74
|
||||||
|
WHEN 81 THEN 80
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END;
|
END;
|
||||||
|
|
||||||
@@ -1644,4 +1646,371 @@ AFTER INSERT ON channel_names_state
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||||
|
|
||||||
|
|
||||||
|
-- v18 integrated fresh-schema definitions
|
||||||
|
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||||
|
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
account_role TEXT,
|
||||||
|
shine_status TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||||
|
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||||
|
|
||||||
|
ALTER TABLE user_stats_state
|
||||||
|
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE message_stats
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_first_name TEXT := '';
|
||||||
|
v_last_name TEXT := '';
|
||||||
|
v_ava_ar TEXT := '';
|
||||||
|
v_account_role TEXT := NULL;
|
||||||
|
v_shine TEXT := NULL;
|
||||||
|
v_updated BIGINT := 0;
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||||
|
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||||
|
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||||
|
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||||
|
MAX(value) FILTER (WHERE param = 'shine'),
|
||||||
|
COALESCE(MAX(time_ms), 0)
|
||||||
|
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||||
|
FROM users_params
|
||||||
|
WHERE LOWER(login) = LOWER(p_login);
|
||||||
|
|
||||||
|
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||||
|
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||||
|
|
||||||
|
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||||
|
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||||
|
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||||
|
|
||||||
|
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||||
|
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
ava_ar = EXCLUDED.ava_ar,
|
||||||
|
account_role = EXCLUDED.account_role,
|
||||||
|
shine_status = EXCLUDED.shine_status,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = p_login
|
||||||
|
LIMIT 1
|
||||||
|
) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||||
|
following_channels_count, close_friends_count, friends_count,
|
||||||
|
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||||
|
SELECT p_login,
|
||||||
|
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||||
|
0,
|
||||||
|
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
|
||||||
|
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||||
|
v_now
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||||
|
following_users_count=0,
|
||||||
|
following_channels_count=EXCLUDED.following_channels_count,
|
||||||
|
close_friends_count=EXCLUDED.close_friends_count,
|
||||||
|
friends_count=EXCLUDED.friends_count,
|
||||||
|
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||||
|
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||||
|
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||||
|
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM shine_refresh_user_stats(p_login);
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT x.login FROM (
|
||||||
|
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
UNION
|
||||||
|
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
) x
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_stats(r.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
LOOP
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||||
|
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||||
|
AND shine_is_primary(x.from_login)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||||
|
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||||
|
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||||
|
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_owner_login TEXT;
|
||||||
|
v_channel_type INTEGER;
|
||||||
|
v_count INTEGER := 0;
|
||||||
|
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT cn.owner_login, cn.channel_type_code
|
||||||
|
INTO v_owner_login, v_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name=p_bch_name
|
||||||
|
AND cn.channel_root_block_number=p_block_number
|
||||||
|
AND cn.channel_root_block_hash=p_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||||
|
INTO v_count
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type=30
|
||||||
|
AND cs.to_bch_name=p_bch_name
|
||||||
|
AND cs.to_block_number=p_block_number
|
||||||
|
AND cs.to_block_hash=p_block_hash;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||||
|
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||||
|
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login=EXCLUDED.owner_login,
|
||||||
|
channel_type_code=EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count=EXCLUDED.subscribers_count,
|
||||||
|
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.channel_type_code = 1 THEN
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
resolved_login TEXT;
|
||||||
|
positive_rel_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
|
||||||
|
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||||
|
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||||
|
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
|
||||||
|
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||||
|
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||||
|
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
|
||||||
|
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
|
||||||
|
ELSE
|
||||||
|
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
|
||||||
|
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||||
|
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
|
||||||
|
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
|
||||||
|
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||||
|
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.login);
|
||||||
|
PERFORM shine_refresh_user_stats(resolved_login);
|
||||||
|
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||||
|
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE previous_sub_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||||
|
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||||
|
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||||
|
|
||||||
|
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||||
|
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=likes_count+1,
|
||||||
|
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||||
|
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||||
|
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||||
|
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||||
|
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||||
|
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||||
|
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||||
|
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Initial projection/rebuild.
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||||
|
END $$;
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||||
|
END $$;
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||||
|
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||||
|
AND shine_is_primary(rs.from_login)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||||
|
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
|
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
|
|||||||
+12
@@ -88,13 +88,19 @@ 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_ListGroupChats200_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_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.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_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
@@ -207,8 +213,11 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
|
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||||
|
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||||
|
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
@@ -295,8 +304,11 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
|
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||||
|
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||||
|
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
|
|||||||
+49
-10
@@ -563,6 +563,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
be.setEditedByBlockNumber(be.getToBlockNumber());
|
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;
|
UserParamEntry upsertedParam = null;
|
||||||
if (block.body instanceof UserParamBody upBody) {
|
if (block.body instanceof UserParamBody upBody) {
|
||||||
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
|
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
|
||||||
@@ -609,6 +616,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
/* ====================== Helpers ====================================== */
|
/* ====================== 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) {
|
private static byte[] decodeBase64(String b64) {
|
||||||
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
||||||
return Base64Ws.decode(b64);
|
return Base64Ws.decode(b64);
|
||||||
@@ -884,16 +916,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection notifications are intentionally modeled as a generic kind.
|
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
if (msgType == 3
|
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
&& block.body instanceof ConnectionBody) {
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
entry.setNotificationKind("connection");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
entry.setSourceText("");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
return entry;
|
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||||
|
if (personalConnection || channelEvent) {
|
||||||
|
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||||
|
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||||
|
entry.setSourceText("");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+11
@@ -138,6 +138,17 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
dao.insertAll(c, entries);
|
dao.insertAll(c, entries);
|
||||||
|
|
||||||
applyMetaUpdates(c, dao, entries, skipped);
|
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();
|
c.commit();
|
||||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||||
if (!conflicts.isEmpty()) {
|
if (!conflicts.isEmpty()) {
|
||||||
|
|||||||
+6
-2
@@ -342,8 +342,10 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
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 likesCount = 0;
|
||||||
|
int primaryLikesCount = 0;
|
||||||
|
int shiningLikesCount = 0;
|
||||||
int repliesCount = 0;
|
int repliesCount = 0;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, bch);
|
||||||
@@ -352,6 +354,8 @@ final class ChannelsReadSupport {
|
|||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
likesCount = rs.getInt("likes_count");
|
likesCount = rs.getInt("likes_count");
|
||||||
|
primaryLikesCount = rs.getInt("primary_likes_count");
|
||||||
|
shiningLikesCount = rs.getInt("shining_likes_count");
|
||||||
repliesCount = rs.getInt("replies_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 {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
|
|||||||
+2
@@ -169,6 +169,8 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
|
item.setPrimaryLikesCount(stats[3]);
|
||||||
|
item.setShiningLikesCount(stats[4]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
item.setRatingsCount(stats[2]);
|
item.setRatingsCount(stats[2]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
|||||||
+2
@@ -242,6 +242,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
|
node.setPrimaryLikesCount(stats[3]);
|
||||||
|
node.setShiningLikesCount(stats[4]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
node.setRatingsCount(stats[2]);
|
node.setRatingsCount(stats[2]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
|
|||||||
+6
@@ -139,6 +139,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String text;
|
private String text;
|
||||||
private int likesCount;
|
private int likesCount;
|
||||||
|
private int primaryLikesCount;
|
||||||
|
private int shiningLikesCount;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
private int ratingsCount;
|
private int ratingsCount;
|
||||||
@@ -191,6 +193,10 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
|
|
||||||
public int getLikesCount() { return likesCount; }
|
public int getLikesCount() { return likesCount; }
|
||||||
public void setLikesCount(int likesCount) { this.likesCount = 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 boolean isLikedByMe() { return likedByMe; }
|
||||||
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
||||||
|
|||||||
+113
-41
@@ -46,8 +46,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(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_CLOSE_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> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||||
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(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);
|
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> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||||
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||||
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(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);
|
// Legacy 60/61 are accepted by the protocol but intentionally not surfaced by current UI.
|
||||||
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
List<String> outKnownPersons = List.of();
|
||||||
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
List<String> inKnownPersons = List.of();
|
||||||
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
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> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||||
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||||
|
|
||||||
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
||||||
allLogins.add(canonicalLogin);
|
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,
|
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);
|
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||||
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
||||||
@@ -86,6 +91,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
resp.setLogin(canonicalLogin);
|
resp.setLogin(canonicalLogin);
|
||||||
resp.setOutFriends(outFriends);
|
resp.setOutFriends(outFriends);
|
||||||
resp.setInFriends(inFriends);
|
resp.setInFriends(inFriends);
|
||||||
|
resp.setOutCloseFriends(outCloseFriends);
|
||||||
|
resp.setInCloseFriends(inCloseFriends);
|
||||||
resp.setOutContacts(outContacts);
|
resp.setOutContacts(outContacts);
|
||||||
resp.setInContacts(inContacts);
|
resp.setInContacts(inContacts);
|
||||||
resp.setOutFollows(outFollows);
|
resp.setOutFollows(outFollows);
|
||||||
@@ -100,6 +107,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
resp.setInSiblings(inSiblings);
|
resp.setInSiblings(inSiblings);
|
||||||
resp.setOutKnownPersons(outKnownPersons);
|
resp.setOutKnownPersons(outKnownPersons);
|
||||||
resp.setInKnownPersons(inKnownPersons);
|
resp.setInKnownPersons(inKnownPersons);
|
||||||
|
resp.setOutOfficialAccounts(outOfficialAccounts);
|
||||||
|
resp.setInOfficialAccounts(inOfficialAccounts);
|
||||||
resp.setOutShineConfirmed(outShineConfirmed);
|
resp.setOutShineConfirmed(outShineConfirmed);
|
||||||
resp.setInShineConfirmed(inShineConfirmed);
|
resp.setInShineConfirmed(inShineConfirmed);
|
||||||
resp.setOutShineSeen(outShineSeen);
|
resp.setOutShineSeen(outShineSeen);
|
||||||
@@ -108,11 +117,49 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
||||||
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
||||||
resp.setSpouses(toRelativeItems(spouseLogins, 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;
|
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 {
|
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
|
||||||
String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su")
|
String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su")
|
||||||
+ " WHERE LOWER(login) = LOWER(?) LIMIT 1";
|
+ " 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 {
|
private Map<String, UserMeta> loadUserMeta(Connection c, Set<String> logins) throws Exception {
|
||||||
Map<String, UserMeta> out = new HashMap<>();
|
Map<String, UserMeta> out = new HashMap<>();
|
||||||
if (logins == null || logins.isEmpty()) return out;
|
if (logins == null || logins.isEmpty()) return out;
|
||||||
|
|
||||||
String[] placeholders = new String[logins.size()];
|
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 = """
|
String sql = """
|
||||||
SELECT
|
SELECT su.login,
|
||||||
su.login AS login,
|
COALESCE(ups.first_name,'') first_name,
|
||||||
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
|
COALESCE(ups.last_name,'') last_name,
|
||||||
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
|
ups.account_role, ups.shine_status, COALESCE(ups.ava_ar,'') avatar_value,
|
||||||
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
|
COALESCE(ust.primary_confirmations_received_count,0) primary_confirmations_count,
|
||||||
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
|
COALESCE(ust.shine_confirmations_received_count,0) shine_confirmations_count,
|
||||||
|
MAX(CASE WHEN up.param='gender' THEN up.value END) gender_value
|
||||||
FROM %s
|
FROM %s
|
||||||
LEFT JOIN users_params up
|
LEFT JOIN user_profile_state ups ON LOWER(ups.login)=LOWER(su.login)
|
||||||
ON LOWER(up.login) = LOWER(su.login)
|
LEFT JOIN user_stats_state ust ON LOWER(ust.login)=LOWER(su.login)
|
||||||
AND up.param IN ('gender', 'official', 'shine', 'ava')
|
LEFT JOIN users_params up ON LOWER(up.login)=LOWER(su.login) AND up.param='gender'
|
||||||
WHERE LOWER(su.login) IN (%s)
|
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
|
ORDER BY su.login
|
||||||
""".formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders));
|
""".formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders));
|
||||||
|
try(PreparedStatement ps=c.prepareStatement(sql)){
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
int i=1; for(String login:logins) ps.setString(i++,normKey(login));
|
||||||
int i = 1;
|
try(ResultSet rs=ps.executeQuery()){while(rs.next()){
|
||||||
for (String login : logins) {
|
String login=rs.getString("login"); if(login==null||login.isBlank()) continue;
|
||||||
ps.setString(i, normKey(login));
|
UserMeta meta=new UserMeta(); meta.firstName=rs.getString("first_name"); meta.lastName=rs.getString("last_name");
|
||||||
i += 1;
|
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);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
meta.primaryConfirmationsCount=rs.getInt("primary_confirmations_count"); meta.shineConfirmationsCount=rs.getInt("shine_confirmations_count");
|
||||||
while (rs.next()) {
|
meta.avatarAr=extractArAvatarTxId(rs.getString("avatar_value")); out.put(normKey(login),meta);
|
||||||
String login = rs.getString("login");
|
}}
|
||||||
if (login == null || login.isBlank()) continue;
|
|
||||||
UserMeta meta = new UserMeta();
|
|
||||||
meta.gender = normalizeGender(rs.getString("gender_value"));
|
|
||||||
meta.official = parseToggle(rs.getString("official_value"));
|
|
||||||
meta.shine = parseToggle(rs.getString("shine_value"));
|
|
||||||
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
|
|
||||||
out.put(normKey(login), meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
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) {
|
private boolean parseToggle(String rawValue) {
|
||||||
String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase();
|
String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase();
|
||||||
return "1".equals(v) || "yes".equals(v) || "true".equals(v) || "on".equals(v);
|
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(
|
private List<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
|
||||||
Set<String> logins,
|
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<>();
|
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> items = new ArrayList<>();
|
||||||
if (logins == null) return items;
|
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();
|
Net_GetUserConnectionsGraph_Response.UserMarkItem it = new Net_GetUserConnectionsGraph_Response.UserMarkItem();
|
||||||
it.setLogin(clean);
|
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.setOfficial(official);
|
||||||
it.setShine(shine);
|
it.setShine(shine);
|
||||||
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
||||||
@@ -296,6 +362,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private static final class UserMeta {
|
private static final class UserMeta {
|
||||||
private String gender = "unknown";
|
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 official = false;
|
||||||
private boolean shine = false;
|
private boolean shine = false;
|
||||||
private String avatarAr = null;
|
private String avatarAr = null;
|
||||||
|
|||||||
+20
-2
@@ -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.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.DmDialogStateDAO;
|
import shine.db.dao.DmDialogStateDAO;
|
||||||
|
import shine.db.dao.UserProfileStateDAO;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -29,18 +30,25 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setLogin(ctx.getLogin());
|
resp.setLogin(ctx.getLogin());
|
||||||
resp.setDialogs(toDialogItems(dialogs));
|
resp.setDialogs(toDialogItems(c, dialogs));
|
||||||
return resp;
|
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<>();
|
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
|
||||||
if (dialogs == null) return items;
|
if (dialogs == null) return items;
|
||||||
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
||||||
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
||||||
item.setPeerLogin(dialog.peerLogin());
|
item.setPeerLogin(dialog.peerLogin());
|
||||||
item.setRelationFlag(dialog.relationFlag());
|
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.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||||
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
||||||
item.setUnreadCount(dialog.unreadCount());
|
item.setUnreadCount(dialog.unreadCount());
|
||||||
@@ -49,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
|
||||||
|
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
|
||||||
|
String raw = value == null ? "" : value.trim();
|
||||||
|
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||||
|
if (ar.find()) out.setAr(ar.group(1));
|
||||||
|
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||||
|
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -9,6 +9,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
private String login;
|
private String login;
|
||||||
private List<String> outFriends = new ArrayList<>();
|
private List<String> outFriends = new ArrayList<>();
|
||||||
private List<String> inFriends = 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> outContacts = new ArrayList<>();
|
||||||
private List<String> inContacts = new ArrayList<>();
|
private List<String> inContacts = new ArrayList<>();
|
||||||
private List<String> outFollows = 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> inSiblings = new ArrayList<>();
|
||||||
private List<String> outKnownPersons = new ArrayList<>();
|
private List<String> outKnownPersons = new ArrayList<>();
|
||||||
private List<String> inKnownPersons = 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> outShineConfirmed = new ArrayList<>();
|
||||||
private List<String> inShineConfirmed = new ArrayList<>();
|
private List<String> inShineConfirmed = new ArrayList<>();
|
||||||
private List<String> outShineSeen = new ArrayList<>();
|
private List<String> outShineSeen = new ArrayList<>();
|
||||||
@@ -58,6 +62,15 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
|
|
||||||
public static class UserMarkItem {
|
public static class UserMarkItem {
|
||||||
private String login;
|
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 official;
|
||||||
private boolean shine;
|
private boolean shine;
|
||||||
private String officialLabel;
|
private String officialLabel;
|
||||||
@@ -66,6 +79,24 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public 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 boolean isOfficial() { return official; }
|
||||||
public void setOfficial(boolean official) { this.official = official; }
|
public void setOfficial(boolean official) { this.official = official; }
|
||||||
public boolean isShine() { return shine; }
|
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 void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
|
||||||
public List<String> getInFriends() { return inFriends; }
|
public List<String> getInFriends() { return inFriends; }
|
||||||
public void setInFriends(List<String> inFriends) { this.inFriends = 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 List<String> getOutContacts() { return outContacts; }
|
||||||
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
|
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
|
||||||
public List<String> getInContacts() { return inContacts; }
|
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 void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
|
||||||
public List<String> getInKnownPersons() { return inKnownPersons; }
|
public List<String> getInKnownPersons() { return inKnownPersons; }
|
||||||
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = 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 List<String> getOutShineConfirmed() { return outShineConfirmed; }
|
||||||
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
|
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
|
||||||
public List<String> getInShineConfirmed() { return inShineConfirmed; }
|
public List<String> getInShineConfirmed() { return inShineConfirmed; }
|
||||||
|
|||||||
+27
@@ -17,6 +17,12 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public static class DialogItem {
|
public static class DialogItem {
|
||||||
private String peerLogin;
|
private String peerLogin;
|
||||||
private String relationFlag;
|
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 String lastMessageBlobB64;
|
||||||
private long lastMessageTimeMs;
|
private long lastMessageTimeMs;
|
||||||
private int unreadCount;
|
private int unreadCount;
|
||||||
@@ -26,6 +32,18 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||||
public String getRelationFlag() { return relationFlag; }
|
public String getRelationFlag() { return relationFlag; }
|
||||||
public void setRelationFlag(String relationFlag) { this.relationFlag = 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 String getLastMessageBlobB64() { return lastMessageBlobB64; }
|
||||||
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
||||||
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
||||||
@@ -35,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public boolean isHasDialog() { return hasDialog; }
|
public boolean isHasDialog() { return hasDialog; }
|
||||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Avatar {
|
||||||
|
private String ar;
|
||||||
|
private String sha256Hex;
|
||||||
|
public String getAr() { return ar; }
|
||||||
|
public void setAr(String ar) { this.ar = ar; }
|
||||||
|
public String getSha256Hex() { return sha256Hex; }
|
||||||
|
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-89
@@ -1,94 +1,27 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
|
||||||
import org.slf4j.LoggerFactory;
|
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import shine.db.DbController; import shine.db.dao.*; import shine.db.entities.UserNotificationEntry;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import java.sql.Connection; import java.util.*;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
|
||||||
import server.logic.ws_protocol.WireCodes;
|
|
||||||
import shine.db.DbController;
|
|
||||||
import shine.db.dao.UserNotificationsStateDAO;
|
|
||||||
import shine.db.entities.UserNotificationEntry;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); private static final long HISTORY_MS=60L*24*60*60*1000;
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
@Override
|
Net_GetNotifications_Request req=(Net_GetNotifications_Request)base; if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Операция доступна только для авторизованных пользователей");
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
try(Connection c=DbController.getInstance().getConnection()){
|
||||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||||
return NetExceptionResponseFactory.error(
|
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||||
req,
|
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||||
WireCodes.Status.UNVERIFIED,
|
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||||
"NOT_AUTHENTICATED",
|
r.setReplies(map(nd.listVisible(c,login,"reply",rs,cutoff))); r.setConnections(map(nd.listVisible(c,login,"connection",cs,cutoff))); r.setEvents(map(nd.listVisible(c,login,"event",es,cutoff)));
|
||||||
"Операция доступна только для авторизованных пользователей"
|
}
|
||||||
);
|
r.setRepliesSeenAtMs(rs);r.setConnectionsSeenAtMs(cs);r.setEventsSeenAtMs(es); r.setRepliesUnseenCount(nd.countUnseen(c,login,"reply",rs)); r.setConnectionsUnseenCount(nd.countUnseen(c,login,"connection",cs)); r.setEventsUnseenCount(nd.countUnseen(c,login,"event",es));
|
||||||
}
|
return r;
|
||||||
|
}catch(Exception e){log.error("GetNotifications failed",e);return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"internal_error","Внутренняя ошибка сервера");}
|
||||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
}
|
||||||
if (login.isBlank()) {
|
private List<Net_GetNotifications_Response.NotificationItem> map(List<UserNotificationEntry> rows){ List<Net_GetNotifications_Response.NotificationItem> out=new ArrayList<>(); for(UserNotificationEntry x:rows){ Net_GetNotifications_Response.NotificationItem i=new Net_GetNotifications_Response.NotificationItem(); i.setKind(x.getNotificationKind());i.setCreatedAtMs(x.getCreatedAtMs());i.setSourceLogin(x.getSourceLogin());i.setSourceBlockchainName(x.getSourceBchName());i.setSourceBlockNumber(x.getSourceBlockNumber());i.setSourceBlockHash(hex(x.getSourceBlockHash()));i.setSourceMsgSubType(x.getSourceMsgSubType());i.setConnectionTypeCode("connection".equals(x.getNotificationKind())?x.getSourceMsgSubType():null);i.setSourceText(x.getSourceText());i.setTargetLogin(x.getTargetLogin());i.setTargetBlockchainName(x.getTargetBchName());i.setTargetBlockNumber(x.getTargetBlockNumber());i.setTargetBlockHash(hex(x.getTargetBlockHash()));out.add(i);} return out; }
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
private static String hex(byte[] b){if(b==null)return null;StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||||
}
|
|
||||||
|
|
||||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
|
||||||
|
|
||||||
try (Connection c = DbController.getInstance().getConnection()) {
|
|
||||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
|
||||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
|
||||||
|
|
||||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
|
||||||
resp.setOp(req.getOp());
|
|
||||||
resp.setRequestId(req.getRequestId());
|
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
|
||||||
resp.setLogin(login);
|
|
||||||
resp.setReplies(mapRows(replyRows));
|
|
||||||
resp.setEvents(mapRows(eventRows));
|
|
||||||
return resp;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("GetNotifications failed", e);
|
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
|
||||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
|
||||||
for (UserNotificationEntry row : rows) {
|
|
||||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
|
||||||
item.setKind(row.getNotificationKind());
|
|
||||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
|
||||||
item.setSourceLogin(row.getSourceLogin());
|
|
||||||
item.setSourceBlockchainName(row.getSourceBchName());
|
|
||||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
|
||||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
|
||||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
|
||||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
|
||||||
item.setSourceText(row.getSourceText());
|
|
||||||
item.setTargetLogin(row.getTargetLogin());
|
|
||||||
item.setTargetBlockchainName(row.getTargetBchName());
|
|
||||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
|
||||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
|
||||||
out.add(item);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String bytesToHex(byte[] bytes) {
|
|
||||||
if (bytes == null) return null;
|
|
||||||
char[] HEX = "0123456789abcdef".toCharArray();
|
|
||||||
char[] out = new char[bytes.length * 2];
|
|
||||||
for (int i = 0; i < bytes.length; i++) {
|
|
||||||
int v = bytes[i] & 0xff;
|
|
||||||
out[i * 2] = HEX[v >>> 4];
|
|
||||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
|
||||||
}
|
|
||||||
return new String(out);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public final class Net_SetNotificationState_Handler implements JsonMessageHandler {
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
|
Net_SetNotificationState_Request req=(Net_SetNotificationState_Request)base;
|
||||||
|
if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Требуется авторизация");
|
||||||
|
try{
|
||||||
|
byte[] raw=Base64.getDecoder().decode(String.valueOf(req.getBlobB64()).trim()); NotificationStatePacket p=NotificationStatePacket.parse(raw);
|
||||||
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim(); if(!login.equalsIgnoreCase(p.login)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"LOGIN_MISMATCH","Подпись принадлежит другому пользователю");
|
||||||
|
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
||||||
|
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
||||||
|
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
||||||
|
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
|
||||||
|
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
final class NotificationStatePacket {
|
||||||
|
static final byte[] PREFIX = "SHiNE_NTF".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
static final int STATE_SEEN_WATERMARK = 1;
|
||||||
|
static final int CATEGORY_REPLIES = 1;
|
||||||
|
static final int CATEGORY_CONNECTIONS = 2;
|
||||||
|
static final int CATEGORY_EVENTS = 3;
|
||||||
|
|
||||||
|
final String login; final long timeMs; final long nonce; final int stateType; final int category; final long seenAtMs;
|
||||||
|
final byte[] signedBody; final byte[] signature64; final byte[] rawPacket;
|
||||||
|
private NotificationStatePacket(String login,long timeMs,long nonce,int stateType,int category,long seenAtMs,byte[] signedBody,byte[] signature64,byte[] rawPacket){
|
||||||
|
this.login=login;this.timeMs=timeMs;this.nonce=nonce;this.stateType=stateType;this.category=category;this.seenAtMs=seenAtMs;this.signedBody=signedBody;this.signature64=signature64;this.rawPacket=rawPacket;
|
||||||
|
}
|
||||||
|
static NotificationStatePacket parse(byte[] raw) {
|
||||||
|
if(raw==null||raw.length<PREFIX.length+2+1+1+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
for(int i=0;i<PREFIX.length;i++) if(raw[i]!=PREFIX[i]) throw new IllegalArgumentException("BAD_PREFIX");
|
||||||
|
ByteBuffer bb=ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); bb.position(PREFIX.length);
|
||||||
|
int major=Byte.toUnsignedInt(bb.get()), minor=Byte.toUnsignedInt(bb.get());
|
||||||
|
if(major!=1||minor!=0) throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
int len=Byte.toUnsignedInt(bb.get()); if(len<1||len>60||bb.remaining()<len+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
byte[] lb=new byte[len]; bb.get(lb); for(byte b:lb) if(b<0x20||b>0x7e) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
String login=new String(lb,StandardCharsets.US_ASCII); long timeMs=bb.getLong(); if(timeMs<0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
|
long nonce=Integer.toUnsignedLong(bb.getInt()); int stateType=Byte.toUnsignedInt(bb.get()); if(stateType!=STATE_SEEN_WATERMARK) throw new IllegalArgumentException("BAD_STATE_TYPE");
|
||||||
|
int category=Byte.toUnsignedInt(bb.get()); if(category<1||category>3) throw new IllegalArgumentException("BAD_CATEGORY");
|
||||||
|
long seenAtMs=bb.getLong(); if(seenAtMs<0||bb.remaining()!=64) throw new IllegalArgumentException("BAD_SEEN_TIME");
|
||||||
|
byte[] sig=new byte[64]; bb.get(sig); return new NotificationStatePacket(login,timeMs,nonce,stateType,category,seenAtMs,Arrays.copyOf(raw,raw.length-64),sig,raw);
|
||||||
|
}
|
||||||
|
String categoryName(){ return category==CATEGORY_REPLIES?"replies":category==CATEGORY_CONNECTIONS?"connections":"events"; }
|
||||||
|
}
|
||||||
+4
-2
@@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_GetNotifications_Request extends Net_Request {
|
public class Net_GetNotifications_Request extends Net_Request {
|
||||||
private Integer limit;
|
private Integer limit; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
|
||||||
|
private Boolean countsOnly;
|
||||||
public Integer getLimit() { return limit; }
|
public Integer getLimit() { return limit; }
|
||||||
public void setLimit(Integer limit) { this.limit = limit; }
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getCountsOnly() { return countsOnly; }
|
||||||
|
public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -8,12 +8,23 @@ import java.util.List;
|
|||||||
public class Net_GetNotifications_Response extends Net_Response {
|
public class Net_GetNotifications_Response extends Net_Response {
|
||||||
private String login;
|
private String login;
|
||||||
private List<NotificationItem> replies = new ArrayList<>();
|
private List<NotificationItem> replies = new ArrayList<>();
|
||||||
|
private List<NotificationItem> connections = new ArrayList<>();
|
||||||
private List<NotificationItem> events = new ArrayList<>();
|
private List<NotificationItem> events = new ArrayList<>();
|
||||||
|
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||||
|
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
public List<NotificationItem> getReplies() { return replies; }
|
public List<NotificationItem> getReplies() { return replies; }
|
||||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||||
|
public List<NotificationItem> getConnections() { return connections; }
|
||||||
|
public void setConnections(List<NotificationItem> v) { connections = v; }
|
||||||
|
public long getRepliesSeenAtMs(){return repliesSeenAtMs;} public void setRepliesSeenAtMs(long v){repliesSeenAtMs=v;}
|
||||||
|
public long getConnectionsSeenAtMs(){return connectionsSeenAtMs;} public void setConnectionsSeenAtMs(long v){connectionsSeenAtMs=v;}
|
||||||
|
public long getEventsSeenAtMs(){return eventsSeenAtMs;} public void setEventsSeenAtMs(long v){eventsSeenAtMs=v;}
|
||||||
|
public long getRepliesUnseenCount(){return repliesUnseenCount;} public void setRepliesUnseenCount(long v){repliesUnseenCount=v;}
|
||||||
|
public long getConnectionsUnseenCount(){return connectionsUnseenCount;} public void setConnectionsUnseenCount(long v){connectionsUnseenCount=v;}
|
||||||
|
public long getEventsUnseenCount(){return eventsUnseenCount;} public void setEventsUnseenCount(long v){eventsUnseenCount=v;}
|
||||||
public List<NotificationItem> getEvents() { return events; }
|
public List<NotificationItem> getEvents() { return events; }
|
||||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||||
|
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
public class Net_SetNotificationState_Request extends Net_Request { private String blobB64; public String getBlobB64(){return blobB64;} public void setBlobB64(String v){blobB64=v;} }
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
public class Net_SetNotificationState_Response extends Net_Response { private String category; private long seenAtMs; public String getCategory(){return category;} public void setCategory(String v){category=v;} public long getSeenAtMs(){return seenAtMs;} public void setSeenAtMs(long v){seenAtMs=v;} }
|
||||||
+3
@@ -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;}}}
|
||||||
+14
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -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;}}
|
||||||
+3
@@ -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;}}}
|
||||||
+9
@@ -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;}
|
||||||
|
}
|
||||||
+24
@@ -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;}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -136,11 +136,21 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
resp.setFollowingUsersCount(0);
|
resp.setFollowingUsersCount(0);
|
||||||
resp.setFollowingChannelsCount(0);
|
resp.setFollowingChannelsCount(0);
|
||||||
resp.setCloseFriendsCount(0);
|
resp.setCloseFriendsCount(0);
|
||||||
|
resp.setFriendsCount(0);
|
||||||
|
resp.setPrimaryConfirmationsReceivedCount(0);
|
||||||
|
resp.setPrimaryConfirmationsGivenCount(0);
|
||||||
|
resp.setShineConfirmationsReceivedCount(0);
|
||||||
|
resp.setShineConfirmationsGivenCount(0);
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT owned_public_channels_count,
|
SELECT owned_public_channels_count,
|
||||||
following_users_count,
|
following_users_count,
|
||||||
following_channels_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
|
FROM user_stats_state
|
||||||
WHERE login = ?
|
WHERE login = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -156,6 +166,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
|
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
|
||||||
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
|
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
|
||||||
resp.setCloseFriendsCount(rs.getInt("close_friends_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) {
|
} catch (Exception e) {
|
||||||
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
|
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
|
||||||
|
|||||||
+15
@@ -47,6 +47,11 @@ public class Net_GetUser_Response extends Net_Response {
|
|||||||
private Integer followingUsersCount;
|
private Integer followingUsersCount;
|
||||||
private Integer followingChannelsCount;
|
private Integer followingChannelsCount;
|
||||||
private Integer closeFriendsCount;
|
private Integer closeFriendsCount;
|
||||||
|
private Integer friendsCount;
|
||||||
|
private Integer primaryConfirmationsReceivedCount;
|
||||||
|
private Integer primaryConfirmationsGivenCount;
|
||||||
|
private Integer shineConfirmationsReceivedCount;
|
||||||
|
private Integer shineConfirmationsGivenCount;
|
||||||
|
|
||||||
public Boolean getExists() { return exists; }
|
public Boolean getExists() { return exists; }
|
||||||
public void setExists(Boolean exists) { this.exists = 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 Integer getCloseFriendsCount() { return closeFriendsCount; }
|
||||||
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = 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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -11,6 +11,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
|
|||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
|
import shine.db.dao.UserProfileStateDAO;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.entities.DmDeliveryStateEntry;
|
import shine.db.entities.DmDeliveryStateEntry;
|
||||||
import shine.db.entities.SignedMessageEntry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
@@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
|||||||
resp.setLimit(limit);
|
resp.setLimit(limit);
|
||||||
resp.setHasMore(hasMore);
|
resp.setHasMore(hasMore);
|
||||||
|
|
||||||
|
// Return a normalized peer card together with the dialog. This makes the
|
||||||
|
// chat header self-contained even when /chat/<login> is opened directly.
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin);
|
||||||
|
Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard();
|
||||||
|
peer.setLogin(peerLogin);
|
||||||
|
peer.setFirstName(card.firstName());
|
||||||
|
peer.setLastName(card.lastName());
|
||||||
|
peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin));
|
||||||
|
peer.setAccountRole(card.accountRole());
|
||||||
|
peer.setShineStatus(card.shineStatus());
|
||||||
|
peer.setAvatar(parseAvatar(card.avatarAr()));
|
||||||
|
resp.setPeer(peer);
|
||||||
|
}
|
||||||
|
|
||||||
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||||
for (SignedMessageEntry entry : page) {
|
for (SignedMessageEntry entry : page) {
|
||||||
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||||
@@ -108,4 +127,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) {
|
||||||
|
Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar();
|
||||||
|
String raw = value == null ? "" : value.trim();
|
||||||
|
|
||||||
|
// Do not call Matcher.find() twice on the same matcher: the first successful
|
||||||
|
// call advances it and the second one can make a perfectly valid avatar vanish.
|
||||||
|
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||||
|
if (ar.find()) {
|
||||||
|
out.setAr(ar.group(1));
|
||||||
|
}
|
||||||
|
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||||
|
if (sha.find()) {
|
||||||
|
out.setSha256Hex(sha.group(1).toLowerCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -12,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
|||||||
private boolean hasMore;
|
private boolean hasMore;
|
||||||
private Long nextBeforeTimeMs;
|
private Long nextBeforeTimeMs;
|
||||||
private String nextBeforeMessageKey;
|
private String nextBeforeMessageKey;
|
||||||
|
private PeerCard peer;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
@@ -26,9 +27,46 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
|||||||
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||||
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||||
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||||
|
public PeerCard getPeer() { return peer; }
|
||||||
|
public void setPeer(PeerCard peer) { this.peer = peer; }
|
||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
|
||||||
|
public static class PeerCard {
|
||||||
|
private String login;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private Avatar avatar;
|
||||||
|
private String relationType;
|
||||||
|
private String accountRole;
|
||||||
|
private String shineStatus;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
public Avatar getAvatar() { return avatar; }
|
||||||
|
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||||
|
public String getRelationType() { return relationType; }
|
||||||
|
public void setRelationType(String relationType) { this.relationType = relationType; }
|
||||||
|
public String getAccountRole() { return accountRole; }
|
||||||
|
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||||
|
public String getShineStatus() { return shineStatus; }
|
||||||
|
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Avatar {
|
||||||
|
private String ar;
|
||||||
|
private String sha256Hex;
|
||||||
|
public String getAr() { return ar; }
|
||||||
|
public void setAr(String ar) { this.ar = ar; }
|
||||||
|
public String getSha256Hex() { return sha256Hex; }
|
||||||
|
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class MessageItem {
|
public static class MessageItem {
|
||||||
private String messageKey;
|
private String messageKey;
|
||||||
private String baseKey;
|
private String baseKey;
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.7.6
|
client.version=1.12.0
|
||||||
server.version=1.6.2
|
server.version=1.10.0
|
||||||
|
|||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Build an offline-ready source bundle ZIP.
|
||||||
|
# In addition to the normal source tree, this variant can attach a local
|
||||||
|
# Gradle distribution zip and a helper script that rewrites wrapper URLs to
|
||||||
|
# that local file so the bundle can be used without internet access.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bundle-offline.sh
|
||||||
|
# ./bundle-offline.sh path/to/output.zip
|
||||||
|
#
|
||||||
|
# Expected local asset:
|
||||||
|
# offline/gradle-offline.zip
|
||||||
|
# or a custom path via BUNDLE_OFFLINE_GRADLE_ZIP
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
OUT="${1:-SHiNE-bundle-offline-$(date +%Y%m%d-%H%M%S).zip}"
|
||||||
|
case "$OUT" in
|
||||||
|
/*) ;;
|
||||||
|
*) OUT="$ROOT/$OUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v zip >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: 'zip' is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
LIST="$TMP/files.txt"
|
||||||
|
SAFE_LIST="$TMP/safe-files.txt"
|
||||||
|
STAGE="$TMP/stage"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
# Paths / filenames that must never be bundled.
|
||||||
|
is_denied_path() {
|
||||||
|
local p="/$1"
|
||||||
|
|
||||||
|
case "$p" in
|
||||||
|
*/.git/*|*/.git|\
|
||||||
|
*/.gradle/*|*/.gradle|\
|
||||||
|
*/.gradle-home/*|*/.gradle-home|\
|
||||||
|
*/.idea/*|*/.idea|\
|
||||||
|
*/.vscode/*|*/.vscode|\
|
||||||
|
*/node_modules/*|*/node_modules|\
|
||||||
|
*/target/*|*/target|\
|
||||||
|
*/build/*|*/build|\
|
||||||
|
*/out/*|*/out|\
|
||||||
|
*/bin/*|*/bin|\
|
||||||
|
*/logs/*|*/logs|\
|
||||||
|
*/data/*|*/data|\
|
||||||
|
*/test-ledger/*|*/test-ledger|\
|
||||||
|
*/.anchor/*|*/.anchor|\
|
||||||
|
*/.yarn/*|*/.yarn|\
|
||||||
|
*/.vendor/*|*/.vendor|\
|
||||||
|
*/.agents/*|*/.agents|\
|
||||||
|
*/.codex/*|*/.codex|\
|
||||||
|
*/.claude/*|*/.claude|\
|
||||||
|
*/deploy/backup/archive/*|\
|
||||||
|
*/scripts/*/runs/*|\
|
||||||
|
*/scripts/*/keypairs/*|\
|
||||||
|
*/keys/*|\
|
||||||
|
*/.git-local-backup/*|\
|
||||||
|
*/SHiNE-bundle-*.zip|\
|
||||||
|
*/bundle-offline*.zip)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local base="${p##*/}"
|
||||||
|
local lower
|
||||||
|
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
.env|.env.*|\
|
||||||
|
.debug-token|\
|
||||||
|
.npmrc|.pypirc|.netrc|\
|
||||||
|
credentials|credentials.*|\
|
||||||
|
secrets|secrets.*|\
|
||||||
|
secret|secret.*|\
|
||||||
|
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||||
|
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||||
|
*keypair*.json|\
|
||||||
|
service-account*.json|\
|
||||||
|
firebase-adminsdk*.json|\
|
||||||
|
google-services.json|\
|
||||||
|
validator.log)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||||
|
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||||
|
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||||
|
.ds_store)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_offline_gradle_zip() {
|
||||||
|
local candidate="${BUNDLE_OFFLINE_GRADLE_ZIP:-}"
|
||||||
|
if [[ -n "$candidate" && -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
"$ROOT/offline/gradle-offline.zip" \
|
||||||
|
"$ROOT/offline/gradle-8.14-bin.zip" \
|
||||||
|
"$ROOT/offline/gradle.zip"
|
||||||
|
do
|
||||||
|
if [[ -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
create_offline_helper() {
|
||||||
|
local zip_name="$1"
|
||||||
|
local helper="$STAGE/offline/prepare-local-gradle.sh"
|
||||||
|
local readme="$STAGE/offline/README.txt"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
|
||||||
|
cat > "$helper" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
|
ZIP_PATH="\${1:-\$ROOT/offline/$zip_name}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ZIP_PATH" ]]; then
|
||||||
|
echo "ERROR: offline Gradle zip not found: \$ZIP_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ABS_ZIP="\$(cd -- "\$(dirname -- "\$ZIP_PATH")" && pwd -P)/\$(basename -- "\$ZIP_PATH")"
|
||||||
|
ESCAPED_ABS_ZIP="\${ABS_ZIP//\\\\/\\\\\\\\}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//&/\\\\&}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//|/\\\\|}"
|
||||||
|
|
||||||
|
while IFS= read -r props; do
|
||||||
|
[[ -f "\$props" ]] || continue
|
||||||
|
cp -p "\$props" "\$props.bak"
|
||||||
|
sed -i -e "s|^distributionUrl=.*\$|distributionUrl=file://\$ESCAPED_ABS_ZIP|" "\$props"
|
||||||
|
done < <(find "\$ROOT" -path '*/gradle/wrapper/gradle-wrapper.properties' -type f | sort)
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Gradle wrapper URLs rewritten to the local offline zip.
|
||||||
|
Run now:
|
||||||
|
./gradlew --offline test
|
||||||
|
MSG
|
||||||
|
EOF
|
||||||
|
chmod +x "$helper"
|
||||||
|
|
||||||
|
cat > "$readme" <<EOF
|
||||||
|
Offline Gradle helper
|
||||||
|
|
||||||
|
Included archive:
|
||||||
|
offline/$zip_name
|
||||||
|
|
||||||
|
Helper:
|
||||||
|
offline/prepare-local-gradle.sh
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- backs up each gradle-wrapper.properties as .bak
|
||||||
|
- rewrites wrapper distributionUrl to the local zip in this bundle
|
||||||
|
|
||||||
|
Recommended flow after unpacking:
|
||||||
|
1. cd into the unpacked bundle root
|
||||||
|
2. run ./offline/prepare-local-gradle.sh
|
||||||
|
3. run ./gradlew --offline test
|
||||||
|
|
||||||
|
This bundle is intended for local, network-free verification.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||||
|
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||||
|
else
|
||||||
|
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert to project-relative paths and enforce hard deny rules.
|
||||||
|
: > "$LIST"
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if [[ "$f" = /* ]]; then
|
||||||
|
rel="${f#"$ROOT"/}"
|
||||||
|
else
|
||||||
|
rel="$f"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$rel" == "$OUT" ]] && continue
|
||||||
|
[[ -z "$rel" ]] && continue
|
||||||
|
|
||||||
|
if is_denied_path "$rel"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$LIST"
|
||||||
|
done < "$TMP/files.z"
|
||||||
|
|
||||||
|
sort -u "$LIST" -o "$LIST"
|
||||||
|
|
||||||
|
# Always include Gradle wrapper bootstrap, even though generic JARs are denied.
|
||||||
|
for wrapper_jar in \
|
||||||
|
'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' \
|
||||||
|
'SHiNE-browser-plugin-wallet/gradle/wrapper/gradle-wrapper.jar'
|
||||||
|
do
|
||||||
|
if [[ -f "$ROOT/$wrapper_jar" ]] && ! grep -Fxq "$wrapper_jar" "$LIST"; then
|
||||||
|
printf '%s\n' "$wrapper_jar" >> "$LIST"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
sort -u "$LIST" -o "$LIST"
|
||||||
|
|
||||||
|
# Content scan: fail closed on common credential/private-key patterns.
|
||||||
|
# We scan only text-ish files; grep -I skips binary data.
|
||||||
|
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||||
|
|
||||||
|
: > "$SAFE_LIST"
|
||||||
|
found_secret=0
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[[ -f "$ROOT/$rel" ]] || continue
|
||||||
|
|
||||||
|
# Files that contain examples/templates can legitimately mention secret keys
|
||||||
|
# with placeholders. They are scanned too, but placeholder-looking values
|
||||||
|
# are less likely to match the regex above.
|
||||||
|
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||||
|
echo "BLOCKED: possible secret in $rel" >&2
|
||||||
|
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||||
|
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||||
|
| head -n 3 >&2 || true
|
||||||
|
found_secret=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||||
|
done < "$LIST"
|
||||||
|
|
||||||
|
if (( found_secret != 0 )); then
|
||||||
|
echo >&2
|
||||||
|
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||||
|
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||||
|
echo "ERROR: no files left to bundle." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
OFFLINE_ZIP_SRC=""
|
||||||
|
OFFLINE_ZIP_NAME=""
|
||||||
|
if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||||
|
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||||
|
else
|
||||||
|
echo "ERROR: offline Gradle zip not found." >&2
|
||||||
|
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
||||||
|
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
src="$ROOT/$rel"
|
||||||
|
dst="$STAGE/$rel"
|
||||||
|
mkdir -p "$(dirname -- "$dst")"
|
||||||
|
cp -p "$src" "$dst"
|
||||||
|
done < "$SAFE_LIST"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
cp -p "$OFFLINE_ZIP_SRC" "$STAGE/offline/$OFFLINE_ZIP_NAME"
|
||||||
|
create_offline_helper "$OFFLINE_ZIP_NAME"
|
||||||
|
|
||||||
|
rm -f -- "$OUT"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$STAGE"
|
||||||
|
find . -type f -print | sort | zip -q -9 "$OUT" -@
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Created: $OUT"
|
||||||
|
echo "Files: $(cd "$STAGE" && find . -type f | wc -l | tr -d ' ')"
|
||||||
|
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||||
@@ -416,3 +416,31 @@
|
|||||||
- `limit_too_large`
|
- `limit_too_large`
|
||||||
- `channel_name_already_exists`
|
- `channel_name_already_exists`
|
||||||
- `internal_error`
|
- `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.
|
||||||
|
|||||||
@@ -60,7 +60,8 @@
|
|||||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
| `GetNotifications` | `15_Notifications_API.md` | ответы, связи, события и unread-watermark |
|
||||||
|
| `SetNotificationState` | `15_Notifications_API.md` | подписанное состояние просмотра уведомлений |
|
||||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -1,81 +1,42 @@
|
|||||||
# API для разработчиков: уведомления
|
# API для разработчиков: уведомления
|
||||||
|
|
||||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
Уведомления являются серверной проекцией событий блокчейна. Сервер возвращает все непросмотренные записи независимо от возраста и просмотренные записи не старше 60 дней. Пагинации нет: выдача содержит все непросмотренные и всю доступную 60-дневную просмотренную историю.
|
||||||
|
|
||||||
Текущая операция:
|
## GetNotifications
|
||||||
|
|
||||||
- `GetNotifications`
|
Авторизация обязательна. Обычно payload пустой. Legacy-поле `limit` принимается для совместимости, но в v2 игнорируется. Для обновления badge без загрузки карточек можно передать `{"countsOnly":true}`; тогда массивы лент остаются пустыми, но watermark и `*UnseenCount` возвращаются.
|
||||||
|
|
||||||
## 1. `GetNotifications`
|
Ответ содержит три ленты: `replies`, `connections`, `events`, а также `*SeenAtMs` и `*UnseenCount` для каждой категории.
|
||||||
|
|
||||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
- `replies`: TEXT_REPLY.
|
||||||
|
- `connections`: friend/unfriend, close_friend/unclose_friend, shine confirmed/unconfirmed, official confirmed/unconfirmed. Контакты не создают уведомлений.
|
||||||
|
- `events`: FOLLOW/UNFOLLOW каналов.
|
||||||
|
|
||||||
Возвращаются две отдельные ленты:
|
Фильтр каждой категории: `created_at_ms > seenAtMs OR created_at_ms >= now - 60 days`.
|
||||||
|
|
||||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
## SetNotificationState
|
||||||
- `events` — события добавления в `close_friend`.
|
|
||||||
|
|
||||||
### Запрос
|
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
|
||||||
|
|
||||||
|
Запрос:
|
||||||
```json
|
```json
|
||||||
{
|
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
|
||||||
"op": "GetNotifications",
|
|
||||||
"requestId": "notif-001",
|
|
||||||
"payload": {
|
|
||||||
"login": "alice",
|
|
||||||
"limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
'SHiNE_NTF' 9 bytes ASCII
|
||||||
"op": "GetNotifications",
|
formatVersionMajor u8 = 1
|
||||||
"requestId": "notif-001",
|
formatVersionMinor u8 = 0
|
||||||
"status": 200,
|
loginLen u8
|
||||||
"ok": true,
|
login ASCII[loginLen]
|
||||||
"payload": {
|
timeMs u64
|
||||||
"login": "Alice",
|
nonce u32
|
||||||
"replies": [
|
stateType u8 = 1 (SEEN_WATERMARK)
|
||||||
{
|
category u8 (1 replies, 2 connections, 3 events)
|
||||||
"kind": "reply",
|
seenAtMs u64
|
||||||
"createdAtMs": 1755673200000,
|
signature Ed25519[64]
|
||||||
"sourceLogin": "Bob",
|
|
||||||
"sourceBlockchainName": "bob-001",
|
|
||||||
"sourceBlockNumber": 42,
|
|
||||||
"sourceBlockHash": "ab12...",
|
|
||||||
"sourceMsgSubType": 20,
|
|
||||||
"sourceText": "Спасибо!",
|
|
||||||
"targetLogin": "Alice",
|
|
||||||
"targetBlockchainName": "alice-001",
|
|
||||||
"targetBlockNumber": 18,
|
|
||||||
"targetBlockHash": "cd34..."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"kind": "close_friend",
|
|
||||||
"createdAtMs": 1755673300000,
|
|
||||||
"sourceLogin": "Kate",
|
|
||||||
"sourceBlockchainName": "kate-001",
|
|
||||||
"sourceBlockNumber": 7,
|
|
||||||
"sourceBlockHash": "ef56...",
|
|
||||||
"sourceMsgSubType": 10,
|
|
||||||
"sourceText": "close_friend",
|
|
||||||
"targetLogin": "Alice",
|
|
||||||
"targetBlockchainName": "alice-001",
|
|
||||||
"targetBlockNumber": 0,
|
|
||||||
"targetBlockHash": "0000..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Примечание
|
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
|
||||||
|
|
||||||
- `replies` заполняется только для `TEXT_REPLY`.
|
|
||||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
|
||||||
- Другие типы связей в эту ленту не попадают.
|
|
||||||
|
|||||||
@@ -203,3 +203,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
|||||||
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||||
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||||
|
|
||||||
|
## Мультипрофильный клиент (UI, 2026-09-03)
|
||||||
|
- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM.
|
||||||
|
- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна.
|
||||||
|
- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие.
|
||||||
|
- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным.
|
||||||
|
- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались.
|
||||||
|
|
||||||
|
## UI: видимость пустого диалога после DeleteConversation
|
||||||
|
|
||||||
|
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
|
||||||
|
|
||||||
|
Для списка личных чатов действует правило:
|
||||||
|
|
||||||
|
- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается;
|
||||||
|
- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи;
|
||||||
|
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
|
||||||
|
|
||||||
|
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
|
||||||
|
|||||||
@@ -356,3 +356,12 @@ ReadReceiptBody_v1_0
|
|||||||
|
|
||||||
## Примечание UI списка чатов (2026-08-28)
|
## Примечание UI списка чатов (2026-08-28)
|
||||||
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||||
|
|
||||||
|
## Примечание о мультипрофиле (2026-09-03)
|
||||||
|
Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`).
|
||||||
|
|
||||||
|
## UI-семантика `type=7/8` в списке диалогов
|
||||||
|
|
||||||
|
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||||
|
|
||||||
|
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
|
||||||
|
|||||||
@@ -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 |
@@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
const existing = allClients.find((client) => {
|
const existing = allClients[0] || null;
|
||||||
try {
|
|
||||||
return client.url.includes('/index.html') || client.url.endsWith('/');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const openUrlBase = './index.html';
|
const openUrlBase = './index.html';
|
||||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||||
const openUrl = (action === 'accept' || action === 'decline')
|
const openUrl = (action === 'accept' || action === 'decline')
|
||||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||||
: openUrlBase;
|
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
try {
|
||||||
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
action,
|
action,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
existing.postMessage({
|
||||||
|
type: 'SHINE_NOTIFICATION_CLICK',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
await existing.focus();
|
await existing.focus();
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||||
<title>СИЯНИЕ</title>
|
<title>СИЯНИЕ</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260826145335';
|
window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+105
-5
@@ -33,6 +33,9 @@ import {
|
|||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
hydrateMessagesFromStore,
|
hydrateMessagesFromStore,
|
||||||
|
getSavedProfiles,
|
||||||
|
closeSavedProfile,
|
||||||
|
switchToSavedProfile,
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
setSessionAuthorizedHandler,
|
setSessionAuthorizedHandler,
|
||||||
@@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
|||||||
|
|
||||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||||
import * as profileEditView from './pages/profile-edit-view.js';
|
import * as profileEditView from './pages/profile-edit-view.js';
|
||||||
|
import * as profilesView from './pages/profiles-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
import * as accessServersView from './pages/access-servers-view.js';
|
import * as accessServersView from './pages/access-servers-view.js';
|
||||||
@@ -91,6 +95,8 @@ import * as messagesList from './pages/messages-list.js?v=202608221218';
|
|||||||
import * as contactSearchView from './pages/contact-search-view.js';
|
import * as contactSearchView from './pages/contact-search-view.js';
|
||||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||||
import * as userProfileView from './pages/user-profile-view.js';
|
import * as userProfileView from './pages/user-profile-view.js';
|
||||||
|
import * as 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 channelsList from './pages/channels-list.js?v=202608221218';
|
||||||
import * as channelView from './pages/channel-view.js';
|
import * as channelView from './pages/channel-view.js';
|
||||||
import * as channelAboutView from './pages/channel-about-view.js';
|
import * as channelAboutView from './pages/channel-about-view.js';
|
||||||
@@ -130,6 +136,7 @@ const routes = {
|
|||||||
queue: publicSupportQueueView,
|
queue: publicSupportQueueView,
|
||||||
'profile-view': profileView,
|
'profile-view': profileView,
|
||||||
'profile-edit-view': profileEditView,
|
'profile-edit-view': profileEditView,
|
||||||
|
'profiles-view': profilesView,
|
||||||
'wallet-view': walletView,
|
'wallet-view': walletView,
|
||||||
'settings-view': settingsView,
|
'settings-view': settingsView,
|
||||||
'access-servers-view': accessServersView,
|
'access-servers-view': accessServersView,
|
||||||
@@ -154,6 +161,8 @@ const routes = {
|
|||||||
'contact-search-view': contactSearchView,
|
'contact-search-view': contactSearchView,
|
||||||
'chat-view': chatView,
|
'chat-view': chatView,
|
||||||
user: userProfileView,
|
user: userProfileView,
|
||||||
|
'user-profile-list-view': userProfileListView,
|
||||||
|
'user-relation-manage-view': userRelationManageView,
|
||||||
'channels-list': channelsList,
|
'channels-list': channelsList,
|
||||||
'channel-view': channelView,
|
'channel-view': channelView,
|
||||||
'channel-about-view': channelAboutView,
|
'channel-about-view': channelAboutView,
|
||||||
@@ -209,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
|||||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||||
'settings-view',
|
'settings-view',
|
||||||
|
'profiles-view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||||
@@ -741,6 +751,77 @@ function consumeCallPushActionFromUrlIfAny() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function pushTargetLogin(payload = {}) {
|
||||||
|
return String(payload?.toLogin || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTargetPath(payload = {}) {
|
||||||
|
const kind = String(payload?.kind || '').trim();
|
||||||
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||||
|
if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`;
|
||||||
|
return '/profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedProfileExists(login) {
|
||||||
|
const normalized = String(login || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) {
|
||||||
|
const targetLogin = pushTargetLogin(payload);
|
||||||
|
const currentLogin = String(state.session.login || '').trim();
|
||||||
|
if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true;
|
||||||
|
if (!savedProfileExists(targetLogin)) {
|
||||||
|
showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = String(payload?.kind || '').trim();
|
||||||
|
const question = kind === 'incoming_call'
|
||||||
|
? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?`
|
||||||
|
: `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`;
|
||||||
|
if (!window.confirm(question)) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await switchToSavedProfile(targetLogin);
|
||||||
|
if (action === 'accept' || action === 'decline') {
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
window.location.assign(pushTargetPath(payload));
|
||||||
|
} else {
|
||||||
|
window.location.assign(pushTargetPath(payload));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotificationClick(payload = {}) {
|
||||||
|
const canOpen = await ensurePushTargetProfile(payload);
|
||||||
|
if (!canOpen) return;
|
||||||
|
const path = pushTargetPath(payload);
|
||||||
|
navigate(path.replace(/^\//, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeNotificationOpenFromUrlIfAny() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const rawPayload = String(params.get('pushOpenPayload') || '');
|
||||||
|
if (!rawPayload) return null;
|
||||||
|
let payload = {};
|
||||||
|
try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {}
|
||||||
|
params.delete('pushOpenPayload');
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`);
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processPendingCallPushActionIfPossible() {
|
async function processPendingCallPushActionIfPossible() {
|
||||||
if (!state.session.isAuthorized) return;
|
if (!state.session.isAuthorized) return;
|
||||||
const pending = loadPendingCallPushAction();
|
const pending = loadPendingCallPushAction();
|
||||||
@@ -1193,7 +1274,8 @@ function renderApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||||
|
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) {
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1282,6 +1364,11 @@ async function tryAutoLogin() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
|
const result = await closeSavedProfile(state.session.login);
|
||||||
|
if (result?.nextProfile) {
|
||||||
|
window.location.assign('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await terminateCurrentSession({
|
await terminateCurrentSession({
|
||||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||||
});
|
});
|
||||||
@@ -1334,6 +1421,7 @@ async function ensureSessionRuntimeStarted() {
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
consumeCallPushActionFromUrlIfAny();
|
consumeCallPushActionFromUrlIfAny();
|
||||||
|
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||||
void tryLockPortraitOrientation();
|
void tryLockPortraitOrientation();
|
||||||
|
|
||||||
if (state.session.isLocalDemo) {
|
if (state.session.isLocalDemo) {
|
||||||
@@ -1369,12 +1457,20 @@ async function init() {
|
|||||||
const action = String(data.action || '').trim().toLowerCase();
|
const action = String(data.action || '').trim().toLowerCase();
|
||||||
const payload = data.payload || {};
|
const payload = data.payload || {};
|
||||||
if (action === 'accept' || action === 'decline') {
|
if (action === 'accept' || action === 'decline') {
|
||||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
void (async () => {
|
||||||
savePendingCallPushAction(action, payload);
|
const canHandle = await ensurePushTargetProfile(payload, { action });
|
||||||
void processPendingCallPushActionIfPossible();
|
if (!canHandle) return;
|
||||||
|
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||||
|
savePendingCallPushAction(action, payload);
|
||||||
|
await processPendingCallPushActionIfPossible();
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type === 'SHINE_NOTIFICATION_CLICK') {
|
||||||
|
void handleNotificationClick(data.payload || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||||
|
|
||||||
const payload = data.payload || {};
|
const payload = data.payload || {};
|
||||||
@@ -1407,7 +1503,8 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authService.onEvent('SessionRevoked', async () => {
|
authService.onEvent('SessionRevoked', async () => {
|
||||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
const result = await closeSavedProfile(state.session.login);
|
||||||
|
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||||
});
|
});
|
||||||
|
|
||||||
authService.onEvent('ForceUiReload', async (evt) => {
|
authService.onEvent('ForceUiReload', async (evt) => {
|
||||||
@@ -1710,6 +1807,9 @@ async function init() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await tryAutoLogin();
|
await tryAutoLogin();
|
||||||
|
if (initialNotificationOpenPayload) {
|
||||||
|
await handleNotificationClick(initialNotificationOpenPayload);
|
||||||
|
}
|
||||||
await hydrateMessagesFromStore();
|
await hydrateMessagesFromStore();
|
||||||
if (!state.session.isLocalDemo) {
|
if (!state.session.isLocalDemo) {
|
||||||
startConnectionMonitor();
|
startConnectionMonitor();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state, authService } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const isProfile = item.pageId === 'profile-view';
|
const isProfile = item.pageId === 'profile-view';
|
||||||
const isMessages = item.pageId === 'messages-list';
|
const isMessages = item.pageId === 'messages-list';
|
||||||
const isNetwork = item.pageId === 'network-view';
|
const isNetwork = item.pageId === 'network-view';
|
||||||
|
const isNotifications = item.pageId === 'notifications-view';
|
||||||
|
btn.dataset.toolbarPage = item.pageId;
|
||||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||||
if (isProfile) {
|
if (isProfile) {
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
@@ -97,6 +99,14 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||||
btn.append(badge);
|
btn.append(badge);
|
||||||
}
|
}
|
||||||
|
if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'toolbar-unread-badge notification-toolbar-badge';
|
||||||
|
const n = Number(state.notificationUnreadTotal || 0);
|
||||||
|
badge.textContent = n > 99 ? '99+' : String(n);
|
||||||
|
badge.setAttribute('aria-label', `Новых уведомлений: ${n}`);
|
||||||
|
btn.append(badge);
|
||||||
|
}
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
btn.addEventListener('click', () => navigate('channels-list'));
|
btn.addEventListener('click', () => navigate('channels-list'));
|
||||||
} else {
|
} else {
|
||||||
@@ -105,5 +115,19 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
root.append(btn);
|
root.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
|
||||||
|
void authService.getNotifications(true).then((payload) => {
|
||||||
|
const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0);
|
||||||
|
state.notificationUnreadTotal = total;
|
||||||
|
const btn = root.querySelector('[data-toolbar-page="notifications-view"]');
|
||||||
|
if (!btn) return;
|
||||||
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||||
|
if (total <= 0) { badge?.remove(); return; }
|
||||||
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||||
|
badge.textContent = total > 99 ? '99+' : String(total);
|
||||||
|
badge.setAttribute('aria-label', `Новых уведомлений: ${total}`);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,6 +775,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
||||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
||||||
const likes = Number(node?.likesCount || 0);
|
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 replies = Number(node?.repliesCount || 0);
|
||||||
const ratings = Number(node?.ratingsCount || 0);
|
const ratings = Number(node?.ratingsCount || 0);
|
||||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
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 = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter">${likes}</span>
|
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||||
`;
|
`;
|
||||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
@@ -962,24 +964,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const ratingButton = document.createElement('button');
|
// Rating/opinion action is intentionally hidden from UI for now.
|
||||||
ratingButton.type = 'button';
|
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||||
ratingButton.className = 'channel-action-item thread-rating-btn';
|
|
||||||
ratingButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
|
||||||
<span class="channel-action-label">Оценка</span>
|
|
||||||
<span class="channel-action-counter">${ratings}</span>
|
|
||||||
`;
|
|
||||||
setActionTitle(ratingButton, 'Оценка');
|
|
||||||
ratingButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openReplyModal({
|
|
||||||
navigate: handlers.navigate,
|
|
||||||
mode: 'rating',
|
|
||||||
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -997,7 +983,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
// Репосты временно отключены до будущей реализации.
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
actions.append(likeButton, replyButton, shareButton);
|
||||||
if (repostTarget) {
|
if (repostTarget) {
|
||||||
const originalButton = document.createElement('button');
|
const originalButton = document.createElement('button');
|
||||||
originalButton.type = 'button';
|
originalButton.type = 'button';
|
||||||
|
|||||||
@@ -1417,6 +1417,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
versionsTotal: Number(message?.versionsTotal || 1),
|
versionsTotal: Number(message?.versionsTotal || 1),
|
||||||
versions: Array.isArray(message?.versions) ? message.versions : [],
|
versions: Array.isArray(message?.versions) ? message.versions : [],
|
||||||
likesCount: Number(message?.likesCount || 0),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
|
primaryLikesCount: Number(message?.primaryLikesCount || 0),
|
||||||
|
shiningLikesCount: Number(message?.shiningLikesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
ratingsCount: Number(message?.ratingsCount || 0),
|
ratingsCount: Number(message?.ratingsCount || 0),
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
@@ -1961,7 +1963,7 @@ function renderPostCard(post, {
|
|||||||
likeButton.innerHTML = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
|
||||||
`;
|
`;
|
||||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
@@ -1996,27 +1998,10 @@ function renderPostCard(post, {
|
|||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const ratingButton = document.createElement('button');
|
// Rating/opinion action is intentionally hidden from UI for now.
|
||||||
ratingButton.type = 'button';
|
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||||
ratingButton.className = 'channel-action-item channel-action-rating';
|
|
||||||
ratingButton.innerHTML = `
|
actions.append(likeButton, replyButton);
|
||||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
|
||||||
<span class="channel-action-label">Оценка</span>
|
|
||||||
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
|
||||||
`;
|
|
||||||
setActionTitle(ratingButton, 'Оценка');
|
|
||||||
ratingButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openReplyModal({
|
|
||||||
navigate,
|
|
||||||
mode: 'rating',
|
|
||||||
onSubmit: async (text) => onRating(post.messageRef, text),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
|
||||||
actions.append(likeButton, replyButton, ratingButton);
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -2281,9 +2266,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
|
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
header.classList.add('channel-view-topbar');
|
||||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
|
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2559,6 +2547,36 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (aboutRoute) navigate(aboutRoute);
|
if (aboutRoute) navigate(aboutRoute);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (channelMoreButton) {
|
||||||
|
channelMoreButton.disabled = false;
|
||||||
|
channelMoreButton.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
header.querySelector('.channel-header-more-menu')?.remove();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'channel-header-more-menu';
|
||||||
|
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
|
||||||
|
about.onclick = () => {
|
||||||
|
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
|
||||||
|
menu.remove(); if (aboutRoute) navigate(aboutRoute);
|
||||||
|
};
|
||||||
|
menu.append(about);
|
||||||
|
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
||||||
|
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
|
||||||
|
unfollow.onclick = async () => {
|
||||||
|
menu.remove();
|
||||||
|
try {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
|
||||||
|
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
|
||||||
|
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
|
||||||
|
};
|
||||||
|
menu.append(unfollow);
|
||||||
|
}
|
||||||
|
header.append(menu);
|
||||||
|
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||||
|
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ function cleanChannelMessagePreview(text) {
|
|||||||
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the channel-list preview tolerant to small API naming changes. The current
|
||||||
|
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
|
||||||
|
// so the third line (last message + time) does not silently disappear.
|
||||||
|
function resolveChannelLastMessage(summary) {
|
||||||
|
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
|
||||||
|
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
|
||||||
|
return {
|
||||||
|
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
|
||||||
|
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
@@ -719,6 +731,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const title = displayTitle || channelName;
|
const title = displayTitle || channelName;
|
||||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||||
|
const lastMessage = resolveChannelLastMessage(summary);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
@@ -737,10 +750,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
channelDescription,
|
channelDescription,
|
||||||
channelTypeCode,
|
channelTypeCode,
|
||||||
channelTypeVersion,
|
channelTypeVersion,
|
||||||
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
|
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||||
messagesCount: Number(summary?.messagesCount || 0),
|
messagesCount: Number(summary?.messagesCount || 0),
|
||||||
unreadCount: Number(summary?.unreadCount || 0),
|
unreadCount: Number(summary?.unreadCount || 0),
|
||||||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||||
isOwnChannel: isOwn,
|
isOwnChannel: isOwn,
|
||||||
isSubscribed: !isOwn,
|
isSubscribed: !isOwn,
|
||||||
notificationsEnabled: notificationsState[rowId] === true,
|
notificationsEnabled: notificationsState[rowId] === true,
|
||||||
|
|||||||
+164
-106
@@ -58,11 +58,11 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
|||||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||||
<span>Связи</span>
|
<span>Показать связи</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||||
<span>Профиль</span>
|
<span>Показать профиль</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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) {
|
function createChatHeaderParts(login, navigate) {
|
||||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||||
|
let currentPeer = {
|
||||||
|
login: cleanLogin,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
avatar: null,
|
||||||
|
relationType: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityButton = document.createElement('button');
|
||||||
|
identityButton.type = 'button';
|
||||||
|
identityButton.className = 'chat-header-peer-btn';
|
||||||
|
identityButton.title = `Меню ${cleanLogin}`;
|
||||||
|
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||||
|
|
||||||
const avatarSlot = document.createElement('span');
|
const avatarSlot = document.createElement('span');
|
||||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
avatarSlot.className = 'chat-header-avatar-slot';
|
||||||
const initialAvatar = renderUserAvatar({
|
const textWrap = document.createElement('span');
|
||||||
login: cleanLogin,
|
textWrap.className = 'chat-header-peer-text';
|
||||||
size: 'md',
|
const nameEl = document.createElement('span');
|
||||||
className: 'chat-header-avatar',
|
nameEl.className = 'chat-header-peer-name';
|
||||||
title: cleanLogin,
|
const metaEl = document.createElement('span');
|
||||||
});
|
metaEl.className = 'chat-header-peer-meta';
|
||||||
avatarSlot.append(initialAvatar);
|
textWrap.append(nameEl, metaEl);
|
||||||
|
identityButton.append(avatarSlot, textWrap);
|
||||||
|
|
||||||
const avatarButton = document.createElement('button');
|
const renderPeer = () => {
|
||||||
avatarButton.type = 'button';
|
const firstName = String(currentPeer?.firstName || '').trim();
|
||||||
avatarButton.className = 'chat-header-avatar-btn';
|
const lastName = String(currentPeer?.lastName || '').trim();
|
||||||
avatarButton.title = `Меню ${cleanLogin}`;
|
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
nameEl.textContent = fullName || cleanLogin;
|
||||||
avatarButton.append(avatarSlot);
|
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||||
|
const avatar = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
avatar: currentPeer?.avatar?.ar
|
||||||
|
? {
|
||||||
|
ar: String(currentPeer.avatar.ar || '').trim(),
|
||||||
|
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'md',
|
||||||
|
className: 'chat-header-avatar',
|
||||||
|
title: cleanLogin,
|
||||||
|
});
|
||||||
|
avatarSlot.replaceChildren(avatar);
|
||||||
|
};
|
||||||
|
|
||||||
const loginEl = document.createElement('button');
|
const updatePeer = (peer) => {
|
||||||
loginEl.type = 'button';
|
if (!peer || typeof peer !== 'object') return;
|
||||||
loginEl.className = 'chat-header-login chat-header-login-btn';
|
currentPeer = {
|
||||||
loginEl.setAttribute('role', 'heading');
|
...currentPeer,
|
||||||
loginEl.setAttribute('aria-level', '1');
|
...peer,
|
||||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
login: String(peer.login || cleanLogin).trim() || cleanLogin,
|
||||||
loginEl.textContent = cleanLogin;
|
relationType: normalizeChatRelationType(peer.relationType),
|
||||||
|
};
|
||||||
|
renderPeer();
|
||||||
|
};
|
||||||
|
|
||||||
void loadProfileSnapshot(cleanLogin)
|
renderPeer();
|
||||||
.then((snapshot) => {
|
identityButton.addEventListener('click', (event) => {
|
||||||
if (!avatarSlot.isConnected) return;
|
|
||||||
const upgradedAvatar = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
firstName: String(snapshot?.firstName || '').trim(),
|
|
||||||
lastName: String(snapshot?.lastName || '').trim(),
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'md',
|
|
||||||
className: 'chat-header-avatar',
|
|
||||||
title: cleanLogin,
|
|
||||||
});
|
|
||||||
avatarSlot.replaceChildren(upgradedAvatar);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
const openMenu = (event) => {
|
|
||||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||||
};
|
});
|
||||||
avatarButton.addEventListener('click', openMenu);
|
|
||||||
loginEl.addEventListener('click', openMenu);
|
|
||||||
|
|
||||||
return { centerNode: loginEl, avatarButton };
|
return {
|
||||||
|
centerNode: identityButton,
|
||||||
|
updatePeer,
|
||||||
|
getPeer: () => ({ ...currentPeer }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
@@ -234,18 +264,30 @@ function openChatConfirmModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
const relation = normalizeChatRelationType(relationType);
|
||||||
|
const isCloseFriend = relation === 'close_friend';
|
||||||
|
const isFriend = relation === 'friend';
|
||||||
|
const isProtectedRelation = isCloseFriend || isFriend;
|
||||||
|
const relationName = isCloseFriend ? 'близких друзей' : 'друзей';
|
||||||
|
const safeName = String(contactName || '').trim() || 'этого пользователя';
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="chat-delete-chat-modal">
|
<div class="modal" id="chat-delete-chat-modal">
|
||||||
<div class="modal-card stack dm-dialog-card">
|
<div class="modal-card stack dm-dialog-card">
|
||||||
<h3 class="modal-title">Удалить чат?</h3>
|
<h3 class="modal-title">Удалить чат?</h3>
|
||||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
${isProtectedRelation ? `
|
||||||
<label class="dm-confirm-check">
|
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
|
||||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
|
||||||
<span>Также удалить всю историю переписки</span>
|
` : `
|
||||||
</label>
|
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
|
||||||
|
<label class="dm-confirm-check">
|
||||||
|
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||||
|
<span>Также удалить всю историю переписки</span>
|
||||||
|
</label>
|
||||||
|
`}
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||||
@@ -260,10 +302,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
|||||||
|
|
||||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||||
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
||||||
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
const deleteHistory = isProtectedRelation
|
||||||
|
? true
|
||||||
|
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||||
close();
|
close();
|
||||||
if (typeof onConfirm === 'function') {
|
if (typeof onConfirm === 'function') {
|
||||||
await onConfirm({ deleteHistory });
|
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -373,8 +417,10 @@ function openMessageActionsMenu({
|
|||||||
function openChatActionsMenu({
|
function openChatActionsMenu({
|
||||||
anchorX = 0,
|
anchorX = 0,
|
||||||
anchorY = 0,
|
anchorY = 0,
|
||||||
|
showAddContact = false,
|
||||||
onCall,
|
onCall,
|
||||||
onVideoCall,
|
onVideoCall,
|
||||||
|
onAddContact,
|
||||||
onClearHistory,
|
onClearHistory,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) {
|
}) {
|
||||||
@@ -387,6 +433,7 @@ function openChatActionsMenu({
|
|||||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||||
|
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true">+</span><span>Добавить в контакты</span></button>` : ''}
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +485,10 @@ function openChatActionsMenu({
|
|||||||
close();
|
close();
|
||||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||||
});
|
});
|
||||||
|
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
|
||||||
|
close();
|
||||||
|
if (typeof onAddContact === 'function') await onAddContact();
|
||||||
|
});
|
||||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||||
close();
|
close();
|
||||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||||
@@ -960,6 +1011,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack dm-screen dm-chat-screen';
|
screen.className = 'stack dm-screen dm-chat-screen';
|
||||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
|
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
||||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||||
let historyHasMore = true;
|
let historyHasMore = true;
|
||||||
@@ -1054,6 +1106,24 @@ export function render({ navigate, route, chrome }) {
|
|||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPeerToContacts = async () => {
|
||||||
|
const approved = await openConfirmContactModal(chatId);
|
||||||
|
if (!approved) return;
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: state.session.login,
|
||||||
|
toLogin: chatId,
|
||||||
|
kind: 'contact',
|
||||||
|
enabled: true,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
peerRelationType = 'contact';
|
||||||
|
chatHeaderParts?.updatePeer?.({ relationType: 'contact' });
|
||||||
|
const contactsPayload = await authService.listContacts();
|
||||||
|
setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []);
|
||||||
|
notifyUnreadStateUpdated();
|
||||||
|
showToast('Добавлено в контакты', { timeoutMs: 1200 });
|
||||||
|
};
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||||
const historyLoader = document.createElement('div');
|
const historyLoader = document.createElement('div');
|
||||||
@@ -1090,8 +1160,16 @@ export function render({ navigate, route, chrome }) {
|
|||||||
openChatActionsMenu({
|
openChatActionsMenu({
|
||||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||||
|
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
|
||||||
onCall: () => handleStartCall('audio'),
|
onCall: () => handleStartCall('audio'),
|
||||||
onVideoCall: () => handleStartCall('video'),
|
onVideoCall: () => handleStartCall('video'),
|
||||||
|
onAddContact: async () => {
|
||||||
|
try {
|
||||||
|
await addPeerToContacts();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
|
}
|
||||||
|
},
|
||||||
onClearHistory: async () => {
|
onClearHistory: async () => {
|
||||||
openChatConfirmModal({
|
openChatConfirmModal({
|
||||||
title: 'Очистить историю?',
|
title: 'Очистить историю?',
|
||||||
@@ -1109,24 +1187,40 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDeleteChat: async () => {
|
onDeleteChat: async () => {
|
||||||
|
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||||
openDeleteChatConfirmModal({
|
openDeleteChatConfirmModal({
|
||||||
contactName: contact.name,
|
contactName: contact.name,
|
||||||
|
relationType: relationBeforeDelete,
|
||||||
onConfirm: async ({ deleteHistory }) => {
|
onConfirm: async ({ deleteHistory }) => {
|
||||||
try {
|
try {
|
||||||
if (deleteHistory) {
|
if (deleteHistory) {
|
||||||
await clearConversationHistory();
|
await clearConversationHistory();
|
||||||
}
|
}
|
||||||
await authService.setUserRelation({
|
|
||||||
login: state.session.login,
|
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
|
||||||
toLogin: chatId,
|
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
|
||||||
kind: 'contact',
|
// закономерно останется в списке из-за действующей связи.
|
||||||
enabled: false,
|
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
? ['close_friend', 'friend', 'contact']
|
||||||
});
|
: ['contact'];
|
||||||
|
for (const kind of relationKinds) {
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: state.session.login,
|
||||||
|
toLogin: chatId,
|
||||||
|
kind,
|
||||||
|
enabled: false,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const contactsPayload = await authService.listContacts();
|
const contactsPayload = await authService.listContacts();
|
||||||
setContacts(contactsPayload?.contacts || []);
|
setContacts(
|
||||||
|
contactsPayload?.contacts
|
||||||
|
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|
||||||
|
|| [],
|
||||||
|
);
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
showToast('Чат удалён', { timeoutMs: 1200 });
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
@@ -1139,48 +1233,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
|
||||||
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
|
||||||
chrome?.setTopbar(chatHeader);
|
chrome?.setTopbar(chatHeader);
|
||||||
|
|
||||||
if (!isKnownContact) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'secondary-btn';
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = 'Добавить собеседника в контакты';
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const approved = await openConfirmContactModal(chatId);
|
|
||||||
if (!approved) return;
|
|
||||||
await authService.setUserRelation({
|
|
||||||
login: state.session.login,
|
|
||||||
toLogin: chatId,
|
|
||||||
kind: 'contact',
|
|
||||||
enabled: true,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
const contactsPayload = await authService.listContacts();
|
|
||||||
setContacts(contactsPayload?.contacts || []);
|
|
||||||
addAppLogEntry({
|
|
||||||
level: 'info',
|
|
||||||
source: 'contacts',
|
|
||||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
|
||||||
});
|
|
||||||
card.remove();
|
|
||||||
} catch (e) {
|
|
||||||
addAppLogEntry({
|
|
||||||
level: 'warn',
|
|
||||||
source: 'contacts',
|
|
||||||
message: 'Не удалось добавить пользователя в контакты',
|
|
||||||
details: { login: chatId, error: e?.message || 'unknown' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
card.append(btn);
|
|
||||||
screen.append(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input dm-chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
@@ -1572,6 +1626,10 @@ export function render({ navigate, route, chrome }) {
|
|||||||
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
|
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
|
||||||
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
|
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
|
||||||
});
|
});
|
||||||
|
if (payload?.peer) {
|
||||||
|
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
|
||||||
|
chatHeaderParts.updatePeer(payload.peer);
|
||||||
|
}
|
||||||
await mergeDirectMessagesPage(chatId, payload?.messages || []);
|
await mergeDirectMessagesPage(chatId, payload?.messages || []);
|
||||||
historyHasMore = Boolean(payload?.hasMore);
|
historyHasMore = Boolean(payload?.hasMore);
|
||||||
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
|
consumeAuthReturnPage,
|
||||||
|
isAddingProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
clearBrowserClientData,
|
clearBrowserClientData,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@@ -205,10 +207,13 @@ export function render({ navigate }) {
|
|||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(session.login).catch(() => {});
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
@@ -221,7 +226,7 @@ export function render({ navigate }) {
|
|||||||
state.loginDraft.password = '';
|
state.loginDraft.password = '';
|
||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
|
consumeAuthReturnPage,
|
||||||
|
isAddingProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
clearBrowserClientData,
|
clearBrowserClientData,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@@ -182,10 +184,13 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(session.login).catch(() => {});
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
@@ -199,7 +204,7 @@ export function render({ navigate }) {
|
|||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
|
||||||
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
showToast(`Устройство подключено для @${resumed.login || session.login}`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => {
|
||||||
@@ -215,10 +220,13 @@ export function render({ navigate }) {
|
|||||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
const addingProfile = isAddingProfileLogin();
|
||||||
await clearStoredMessages().catch(() => {});
|
if (!addingProfile) {
|
||||||
clearBrowserClientData();
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearClientAuthData().catch(() => {});
|
clearBrowserClientData();
|
||||||
|
await clearClientAuthData().catch(() => {});
|
||||||
|
}
|
||||||
|
await clearStoredMessages(login).catch(() => {});
|
||||||
await authService.persistSessionMaterial(login, sessionMaterial);
|
await authService.persistSessionMaterial(login, sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(login, sessionId);
|
const resumed = await authService.resumeSession(login, sessionId);
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
@@ -231,7 +239,7 @@ export function render({ navigate }) {
|
|||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`);
|
||||||
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
showToast(`Wallet-session подключена для @${resumed.login || login}`);
|
||||||
navigate('profile-view');
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const schedulePoll = () => {
|
const schedulePoll = () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
|
cancelAddProfileLogin,
|
||||||
clearAuthMessages,
|
clearAuthMessages,
|
||||||
setAuthBusy,
|
setAuthBusy,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
@@ -155,7 +156,17 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
panel,
|
panel,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,16 +17,21 @@ import { formatRelativeTime } from '../services/channels-ux.js';
|
|||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const PREVIEW_MAX_LEN = 200;
|
const PREVIEW_MAX_LEN = 200;
|
||||||
|
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_CACHE = new Map();
|
||||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||||
const dmAvatarSnapshotCache = new Map();
|
const dmAvatarSnapshotCache = new Map();
|
||||||
const dmAvatarPendingByLogin = 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([
|
const RELATION_ORDER = new Map([
|
||||||
['close_friend', 0],
|
['close_friend', 0],
|
||||||
['contact', 1],
|
['friend', 1],
|
||||||
['none', 2],
|
['contact', 2],
|
||||||
|
['none', 3],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
async function loadDmAvatarSnapshot(login) {
|
async function loadDmAvatarSnapshot(login) {
|
||||||
@@ -50,16 +55,19 @@ async function loadDmAvatarSnapshot(login) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDmAvatar(login, { className = '' } = {}) {
|
function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
|
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',
|
size: 'lg',
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
});
|
});
|
||||||
if (!cleanLogin) return avatarEl;
|
if (!cleanLogin || avatar?.ar) return avatarEl;
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
if (!avatarEl.isConnected) return;
|
if (!avatarEl.isConnected) return;
|
||||||
const upgraded = renderUserAvatar({
|
const upgraded = renderUserAvatar({
|
||||||
@@ -82,7 +90,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
|||||||
|
|
||||||
function normalizeRelationFlag(value) {
|
function normalizeRelationFlag(value) {
|
||||||
const clean = String(value || '').trim().toLowerCase();
|
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';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +102,8 @@ function relationLabel(flag) {
|
|||||||
switch (normalizeRelationFlag(flag)) {
|
switch (normalizeRelationFlag(flag)) {
|
||||||
case 'close_friend':
|
case 'close_friend':
|
||||||
return 'близкий друг';
|
return 'близкий друг';
|
||||||
|
case 'friend':
|
||||||
|
return 'друг';
|
||||||
case 'contact':
|
case 'contact':
|
||||||
return 'контакт';
|
return 'контакт';
|
||||||
default:
|
default:
|
||||||
@@ -187,6 +197,7 @@ function compareChatRows(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate, chrome }) {
|
export function render({ navigate, chrome }) {
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
const head = document.createElement('header');
|
const head = document.createElement('header');
|
||||||
@@ -220,6 +231,7 @@ export function render({ navigate, chrome }) {
|
|||||||
const filterLabels = {
|
const filterLabels = {
|
||||||
all: 'Чаты',
|
all: 'Чаты',
|
||||||
close_friend: 'Близкие друзья',
|
close_friend: 'Близкие друзья',
|
||||||
|
friend: 'Друзья',
|
||||||
contact: 'Контакты',
|
contact: 'Контакты',
|
||||||
none: 'Новые',
|
none: 'Новые',
|
||||||
};
|
};
|
||||||
@@ -232,6 +244,7 @@ export function render({ navigate, chrome }) {
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; 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 = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||||
],
|
],
|
||||||
@@ -333,7 +346,11 @@ function renderRow(item) {
|
|||||||
const relationBadge = relationFlag === 'none'
|
const relationBadge = relationFlag === 'none'
|
||||||
? 'не в контактах'
|
? 'не в контактах'
|
||||||
: relationLabel(relationFlag);
|
: 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');
|
avatarEl.classList.add('avatar');
|
||||||
const avatarWrap = document.createElement('div');
|
const avatarWrap = document.createElement('div');
|
||||||
avatarWrap.className = 'dm-av dm-av--default';
|
avatarWrap.className = 'dm-av dm-av--default';
|
||||||
@@ -357,7 +374,10 @@ function renderRow(item) {
|
|||||||
const titleEl = row.querySelector('.dm-row-title');
|
const titleEl = row.querySelector('.dm-row-title');
|
||||||
const previewEl = row.querySelector('.dm-row-last-message');
|
const previewEl = row.querySelector('.dm-row-last-message');
|
||||||
const timeEl = row.querySelector('.dm-row-time');
|
const timeEl = row.querySelector('.dm-row-time');
|
||||||
if (titleEl) titleEl.textContent = 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 (previewEl) previewEl.textContent = 'Загрузка…';
|
||||||
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
||||||
row.prepend(avatarWrap);
|
row.prepend(avatarWrap);
|
||||||
@@ -381,7 +401,10 @@ function renderRow(item) {
|
|||||||
try {
|
try {
|
||||||
const payload = await authService.listContacts();
|
const payload = await authService.listContacts();
|
||||||
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
||||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
const contacts = dialogs
|
||||||
|
.filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none')
|
||||||
|
.map((dialog) => String(dialog?.peerLogin || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
setContacts(contacts);
|
setContacts(contacts);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
@@ -395,6 +418,11 @@ function renderRow(item) {
|
|||||||
id: peerLogin,
|
id: peerLogin,
|
||||||
peerLogin,
|
peerLogin,
|
||||||
relationFlag,
|
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 || ''),
|
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
||||||
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
||||||
unreadCount: Number(dialog?.unreadCount || 0),
|
unreadCount: Number(dialog?.unreadCount || 0),
|
||||||
@@ -420,6 +448,9 @@ function renderRow(item) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = Array.from(byPeer.values())
|
const rows = Array.from(byPeer.values())
|
||||||
|
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
|
||||||
|
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
|
||||||
|
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
|
||||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const orderA = relationOrder(a.relationFlag);
|
const orderA = relationOrder(a.relationFlag);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
import { createForceGraph } from './network/force-graph.js';
|
import { createForceGraph } from './network/force-graph.js';
|
||||||
import { engineModelFromGraphModel } from './network/adapter.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: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
@@ -73,6 +76,12 @@ function getMarkByLogin(allUsers) {
|
|||||||
if (!login) return;
|
if (!login) return;
|
||||||
map.set(normKey(login), {
|
map.set(normKey(login), {
|
||||||
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),
|
official: Boolean(row?.official),
|
||||||
shine: Boolean(row?.shine),
|
shine: Boolean(row?.shine),
|
||||||
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
||||||
@@ -104,10 +113,8 @@ function applyRelativeGender(map, rows) {
|
|||||||
|
|
||||||
function getRelativeGenderMap(graph) {
|
function getRelativeGenderMap(graph) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
applyRelativeGender(map, graph?.parents);
|
// Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды.
|
||||||
applyRelativeGender(map, graph?.children);
|
void graph;
|
||||||
applyRelativeGender(map, graph?.siblings);
|
|
||||||
applyRelativeGender(map, graph?.spouses);
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +122,8 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
|
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
|
||||||
const outFriends = toSet(graph?.outFriends);
|
const outFriends = toSet(graph?.outFriends);
|
||||||
const inFriends = toSet(graph?.inFriends);
|
const inFriends = toSet(graph?.inFriends);
|
||||||
|
const outCloseFriends = toSet(graph?.outCloseFriends);
|
||||||
|
const inCloseFriends = toSet(graph?.inCloseFriends);
|
||||||
const outParents = toSet(graph?.outParents);
|
const outParents = toSet(graph?.outParents);
|
||||||
const inParents = toSet(graph?.inParents);
|
const inParents = toSet(graph?.inParents);
|
||||||
const outChildren = toSet(graph?.outChildren);
|
const outChildren = toSet(graph?.outChildren);
|
||||||
@@ -128,8 +137,8 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
const inContacts = toSet(graph?.inContacts);
|
const inContacts = toSet(graph?.inContacts);
|
||||||
const outFollows = toSet(graph?.outFollows);
|
const outFollows = toSet(graph?.outFollows);
|
||||||
const inFollows = toSet(graph?.inFollows);
|
const inFollows = toSet(graph?.inFollows);
|
||||||
const outKnown = toSet(graph?.outKnownPersons);
|
const outOfficial = toSet(graph?.outOfficialAccounts);
|
||||||
const inKnown = toSet(graph?.inKnownPersons);
|
const inOfficial = toSet(graph?.inOfficialAccounts);
|
||||||
|
|
||||||
const relativesGender = getRelativeGenderMap(graph);
|
const relativesGender = getRelativeGenderMap(graph);
|
||||||
const allMarks = getMarkByLogin(graph?.allUsers);
|
const allMarks = getMarkByLogin(graph?.allUsers);
|
||||||
@@ -137,67 +146,35 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
const allLogins = uniqueLogins([
|
const allLogins = uniqueLogins([
|
||||||
...(graph?.outFriends || []),
|
...(graph?.outFriends || []),
|
||||||
...(graph?.inFriends || []),
|
...(graph?.inFriends || []),
|
||||||
...(graph?.outParents || []),
|
...(graph?.outCloseFriends || []),
|
||||||
...(graph?.inParents || []),
|
...(graph?.inCloseFriends || []),
|
||||||
...(graph?.outChildren || []),
|
|
||||||
...(graph?.inChildren || []),
|
|
||||||
...(graph?.outSiblings || []),
|
|
||||||
...(graph?.inSiblings || []),
|
|
||||||
...(graph?.outSpouses || []),
|
|
||||||
...(graph?.inSpouses || []),
|
|
||||||
...(graph?.outContacts || []),
|
...(graph?.outContacts || []),
|
||||||
...(graph?.inContacts || []),
|
...(graph?.inContacts || []),
|
||||||
...(graph?.outFollows || []),
|
...(graph?.outFollows || []),
|
||||||
...(graph?.inFollows || []),
|
...(graph?.inFollows || []),
|
||||||
...(graph?.outKnownPersons || []),
|
...(graph?.outOfficialAccounts || []),
|
||||||
...(graph?.inKnownPersons || []),
|
...(graph?.inOfficialAccounts || []),
|
||||||
]).filter((entry) => normKey(entry) !== normKey(login));
|
]).filter((entry) => normKey(entry) !== normKey(login));
|
||||||
|
|
||||||
const relations = allLogins.map((targetLogin) => {
|
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 friendOut = hasLogin(outFriends, targetLogin);
|
||||||
const friendIn = hasLogin(inFriends, targetLogin);
|
const friendIn = hasLogin(inFriends, targetLogin);
|
||||||
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
|
const closeFriendOut = hasLogin(outCloseFriends, targetLogin);
|
||||||
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, 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';
|
let role = 'contact';
|
||||||
if (parentOut || parentIn) role = 'parent';
|
if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend';
|
||||||
else if (childOut || childIn) role = 'child';
|
|
||||||
else if (spouseOut || spouseIn) role = 'spouse';
|
|
||||||
else if (siblingOut || siblingIn) role = 'sibling';
|
|
||||||
else if (friendOut || friendIn) role = 'friend';
|
|
||||||
|
|
||||||
let forward = friendOut;
|
let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut;
|
||||||
let backward = friendIn;
|
let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn;
|
||||||
if (role === 'parent') {
|
|
||||||
forward = parentOut;
|
|
||||||
backward = parentIn;
|
|
||||||
} else if (role === 'child') {
|
|
||||||
forward = childOut;
|
|
||||||
backward = childIn;
|
|
||||||
} else if (role === 'spouse') {
|
|
||||||
forward = spouseOut;
|
|
||||||
backward = spouseIn;
|
|
||||||
} else if (role === 'sibling') {
|
|
||||||
forward = siblingOut;
|
|
||||||
backward = siblingIn;
|
|
||||||
} else if (role === 'contact') {
|
|
||||||
forward = contactOut;
|
|
||||||
backward = contactIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: targetLogin,
|
login: targetLogin,
|
||||||
key: normKey(targetLogin),
|
key: normKey(targetLogin),
|
||||||
role,
|
role,
|
||||||
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
|
isRelative: false,
|
||||||
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
|
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
|
||||||
forward: Boolean(forward),
|
forward: Boolean(forward),
|
||||||
backward: Boolean(backward),
|
backward: Boolean(backward),
|
||||||
@@ -237,17 +214,15 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||||
let engine = null;
|
let engine = null;
|
||||||
let sheetEl = null;
|
|
||||||
let loadSeq = 0;
|
let loadSeq = 0;
|
||||||
|
|
||||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||||
const FILTERS = {
|
const FILTERS = {
|
||||||
all: { label: 'Все', pred: () => true },
|
all: { label: 'Все', pred: () => true },
|
||||||
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
|
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
|
||||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
|
|
||||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
||||||
};
|
};
|
||||||
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
const FILTER_ORDER = ['all', 'friends', 'shining'];
|
||||||
let activeFilter = 'all';
|
let activeFilter = 'all';
|
||||||
const filterChips = {};
|
const filterChips = {};
|
||||||
|
|
||||||
@@ -291,7 +266,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
<div class="modal" id="network-search-modal">
|
<div class="modal" id="network-search-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
||||||
<h3 class="modal-title">Найти человека</h3>
|
<h3 class="modal-title">Найти пользователя</h3>
|
||||||
<div class="row" style="gap:8px;">
|
<div class="row" style="gap:8px;">
|
||||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||||
@@ -400,41 +375,6 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
window.setTimeout(() => inputEl.focus(), 0);
|
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) {
|
function ensureEngine(model) {
|
||||||
if (engine) {
|
if (engine) {
|
||||||
engine.setModel(model);
|
engine.setModel(model);
|
||||||
@@ -443,8 +383,8 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
engine = createForceGraph({
|
engine = createForceGraph({
|
||||||
stage: board,
|
stage: board,
|
||||||
model,
|
model,
|
||||||
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
|
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
||||||
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
|
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
|
||||||
// тап по центру — полноценный профиль
|
// тап по центру — полноценный профиль
|
||||||
onCenterTap: (node) => {
|
onCenterTap: (node) => {
|
||||||
const routeTo = profileInfoRoute(node.login);
|
const routeTo = profileInfoRoute(node.login);
|
||||||
@@ -455,6 +395,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
const login = normalizeLogin(node.login);
|
const login = normalizeLogin(node.login);
|
||||||
openNodeMenu({
|
openNodeMenu({
|
||||||
login,
|
login,
|
||||||
|
displayName: String(node.name || '').trim(),
|
||||||
relationType: node.relationType,
|
relationType: node.relationType,
|
||||||
point,
|
point,
|
||||||
actions: [
|
actions: [
|
||||||
@@ -496,15 +437,33 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Найти', onClick: openSearchModal },
|
{
|
||||||
|
iconNode: createOverflowDots(),
|
||||||
|
title: 'Меню связей',
|
||||||
|
ariaLabel: 'Открыть меню связей',
|
||||||
|
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const networkMenuButton = header.querySelector('.network-header-menu-btn');
|
||||||
|
const searchIconHtml = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
|
<path d="M16 16l4 4"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const networkMenu = createDropdownMenu({
|
||||||
|
anchorEl: networkMenuButton,
|
||||||
|
minWidth: 220,
|
||||||
|
items: [
|
||||||
|
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
|
||||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
|
||||||
header.classList.add('network-topbar');
|
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
networkMenu.destroy();
|
||||||
if (engine) engine.destroy();
|
if (engine) engine.destroy();
|
||||||
engine = null;
|
engine = null;
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ function normLogin(value) {
|
|||||||
|
|
||||||
const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling'];
|
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 (FAMILY_ROLES.includes(role)) return 'family';
|
||||||
if (role === 'friend') return 'friend';
|
if (role === 'friend') return 'friend';
|
||||||
return 'contact';
|
return 'contact';
|
||||||
@@ -22,6 +25,7 @@ function relationTypeFromRole(role) {
|
|||||||
// - взаимные дружеские связи ближе односторонних.
|
// - взаимные дружеские связи ближе односторонних.
|
||||||
function deriveStrength(relation) {
|
function deriveStrength(relation) {
|
||||||
if (relation.isRelative) return 0.9;
|
if (relation.isRelative) return 0.9;
|
||||||
|
if (relation?.mark?.relationType === 'close_friend') return 0.9;
|
||||||
if (relation.role === 'contact') return 0.4;
|
if (relation.role === 'contact') return 0.4;
|
||||||
if (relation.forward && relation.backward) return 0.8;
|
if (relation.forward && relation.backward) return 0.8;
|
||||||
return 0.55;
|
return 0.55;
|
||||||
@@ -38,7 +42,7 @@ export function engineModelFromGraphModel(graphModel) {
|
|||||||
const focusNode = {
|
const focusNode = {
|
||||||
id: focusLogin,
|
id: focusLogin,
|
||||||
login: focusLogin,
|
login: focusLogin,
|
||||||
name: '',
|
name: centerMark?.displayName || centerMark?.login || focusLogin,
|
||||||
avatar: centerMark?.avatar || null,
|
avatar: centerMark?.avatar || null,
|
||||||
relationType: 'self',
|
relationType: 'self',
|
||||||
strength: 1,
|
strength: 1,
|
||||||
@@ -58,9 +62,9 @@ export function engineModelFromGraphModel(graphModel) {
|
|||||||
return {
|
return {
|
||||||
id: login,
|
id: login,
|
||||||
login,
|
login,
|
||||||
name: '',
|
name: r?.mark?.displayName || login,
|
||||||
avatar: r?.mark?.avatar || null,
|
avatar: r?.mark?.avatar || null,
|
||||||
relationType: relationTypeFromRole(r?.role),
|
relationType: relationTypeFromRelation(r),
|
||||||
strength: deriveStrength(r || {}),
|
strength: deriveStrength(r || {}),
|
||||||
shining: Boolean(r?.mark?.shine),
|
shining: Boolean(r?.mark?.shine),
|
||||||
tier: 1,
|
tier: 1,
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
|
|||||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
// --- Параметры физики и анимации ---------------------------------------------
|
// --- Параметры физики и анимации ---------------------------------------------
|
||||||
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
const ORBIT_FIRST_R = 100; // первый круг связей: был ~150px, теперь на треть ближе к центру
|
||||||
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
|
const ORBIT_GAP = 87; // между соседними кругами >= 1.5 диаметра аватарки (58px * 1.5)
|
||||||
|
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
||||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||||
@@ -85,6 +86,7 @@ const DOUBLE_TAP_MS = 320; // окно двойного тапа по фо
|
|||||||
const RELATION_COLORS = {
|
const RELATION_COLORS = {
|
||||||
family: 'rgba(255, 159, 94, 0.92)',
|
family: 'rgba(255, 159, 94, 0.92)',
|
||||||
friend: 'rgba(120, 179, 255, 0.9)',
|
friend: 'rgba(120, 179, 255, 0.9)',
|
||||||
|
close_friend: 'rgba(120, 179, 255, 0.98)',
|
||||||
business: 'rgba(190, 150, 255, 0.9)',
|
business: 'rgba(190, 150, 255, 0.9)',
|
||||||
contact: 'rgba(170, 190, 220, 0.7)',
|
contact: 'rgba(170, 190, 220, 0.7)',
|
||||||
};
|
};
|
||||||
@@ -146,11 +148,57 @@ function ensureShineFilter() {
|
|||||||
document.body.appendChild(svg);
|
document.body.appendChild(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
// Равномерный угол по кругу. Небольшой постоянный сдвиг, чтобы первый узел
|
||||||
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
// не «прилипал» к горизонтали.
|
||||||
function spreadAngle(index, total) {
|
function spreadAngle(index, total, phase = 0.52) {
|
||||||
if (total <= 0) return 0;
|
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) — чтобы орбита была органически неровной,
|
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
|
||||||
@@ -360,8 +408,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
const specs = [];
|
const specs = [];
|
||||||
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
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 });
|
const tier1Orbit = buildOrbitPlacements(tier1.length);
|
||||||
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
|
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-й — маленькими аватарками
|
// 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 }));
|
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 };
|
return { focusId: fId, specs };
|
||||||
@@ -370,17 +419,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function buildNodes(srcModel) {
|
function buildNodes(srcModel) {
|
||||||
const { focusId: fId, specs } = computeSpecs(srcModel);
|
const { focusId: fId, specs } = computeSpecs(srcModel);
|
||||||
focusId = fId;
|
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 strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||||
const tier = Number(src.tier) || 1;
|
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?.radius || ORBIT_FIRST_R);
|
||||||
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
|
||||||
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
|
|
||||||
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
||||||
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
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);
|
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||||
@@ -404,6 +452,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
strength,
|
strength,
|
||||||
targetR,
|
targetR,
|
||||||
angle,
|
angle,
|
||||||
|
orbitRing: orbit?.ring || 0,
|
||||||
tx,
|
tx,
|
||||||
ty,
|
ty,
|
||||||
x: tx * INTRO_FACTOR,
|
x: tx * INTRO_FACTOR,
|
||||||
@@ -545,7 +594,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function updateA11y() {
|
function updateA11y() {
|
||||||
const focus = nodes.find((n) => n.isFocus);
|
const focus = nodes.find((n) => n.isFocus);
|
||||||
const tier1 = nodes.filter((n) => n.tier === 1 && !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('');
|
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>`;
|
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.strength = strength;
|
||||||
node.relationType = src.relationType;
|
node.relationType = src.relationType;
|
||||||
node.shining = Boolean(src.shining);
|
node.shining = Boolean(src.shining);
|
||||||
const jr = (hash01(src.id) - 0.5) * 18;
|
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
|
||||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
|
||||||
node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
node.orbitRing = spec.orbit?.ring || 0;
|
||||||
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
|
|
||||||
node.tx = Math.cos(node.angle) * node.targetR;
|
node.tx = Math.cos(node.angle) * node.targetR;
|
||||||
node.ty = Math.sin(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));
|
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;
|
target.vy = 0;
|
||||||
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
|
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
|
||||||
|
|
||||||
peers.forEach((n, i) => {
|
applyOrbitTargets(peers);
|
||||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
peers.forEach((n) => {
|
||||||
n.angle = spreadAngle(i, peers.length);
|
|
||||||
n.vx = 0;
|
n.vx = 0;
|
||||||
n.vy = 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;
|
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 = {
|
tween = {
|
||||||
@@ -1091,11 +1134,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const focus = nodes.find((n) => n.isFocus);
|
const focus = nodes.find((n) => n.isFocus);
|
||||||
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
|
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
|
||||||
|
|
||||||
visiblePeers.forEach((n, i) => {
|
applyOrbitTargets(visiblePeers);
|
||||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
visiblePeers.forEach((n) => {
|
||||||
n.angle = spreadAngle(i, visiblePeers.length);
|
|
||||||
n.tx = Math.cos(n.angle) * n.targetR;
|
|
||||||
n.ty = Math.sin(n.angle) * n.targetR;
|
|
||||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||||
apply(n, n.tx, n.ty, sc, 1);
|
apply(n, n.tx, n.ty, sc, 1);
|
||||||
});
|
});
|
||||||
@@ -1598,7 +1638,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node = oldNode; isNew = false;
|
node = oldNode; isNew = false;
|
||||||
} else {
|
} else {
|
||||||
if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
|
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;
|
isNew = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function relationLabelRu(relationType) {
|
|||||||
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
|
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
|
||||||
* @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню
|
* @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');
|
const root = document.getElementById('modal-root');
|
||||||
if (!(root instanceof HTMLElement)) return;
|
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-overlay" id="fg-menu-overlay">
|
||||||
<div class="fg-menu" id="fg-menu" role="menu">
|
<div class="fg-menu" id="fg-menu" role="menu">
|
||||||
<div class="fg-menu-head">
|
<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>
|
<span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span>
|
||||||
</div>
|
</div>
|
||||||
${itemsHtml}
|
${itemsHtml}
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|||||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
const CONNECTION_CLOSE_FRIEND = 10;
|
const CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
const CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
|
const CONNECTION_FRIEND = 14;
|
||||||
|
const CONNECTION_UNFRIEND = 15;
|
||||||
|
const CONNECTION_FOLLOW = 30;
|
||||||
|
const CONNECTION_UNFOLLOW = 31;
|
||||||
|
const CONNECTION_SHINE_CONFIRMED = 70;
|
||||||
|
const CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||||
|
const CONNECTION_OFFICIAL_CONFIRMED = 80;
|
||||||
|
const CONNECTION_OFFICIAL_UNCONFIRMED = 81;
|
||||||
const profileSnapshotCache = new Map();
|
const profileSnapshotCache = new Map();
|
||||||
const profileSnapshotPending = new Map();
|
const profileSnapshotPending = new Map();
|
||||||
|
|
||||||
function connectionTypeLabel(typeCode) {
|
function connectionActionLabel(typeCode) {
|
||||||
switch (Number(typeCode)) {
|
switch (Number(typeCode)) {
|
||||||
case CONNECTION_CLOSE_FRIEND:
|
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
||||||
return 'близкие друзья';
|
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
||||||
default:
|
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
|
||||||
return 'новую связь';
|
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
|
||||||
|
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
|
||||||
|
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
|
||||||
|
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
|
||||||
|
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
|
||||||
|
default: return 'Изменил(а) связь с вами.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventActionLabel(typeCode) {
|
||||||
|
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
|
||||||
|
return 'Подписался(-ась) на ваш канал.';
|
||||||
|
}
|
||||||
|
|
||||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||||
|
|
||||||
function normalizeItem(item) {
|
function normalizeItem(item) {
|
||||||
@@ -136,12 +155,10 @@ function renderEmpty(activeTab) {
|
|||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack notification-empty-state';
|
card.className = 'card stack notification-empty-state';
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
text.textContent = activeTab === 'events'
|
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
|
||||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
|
||||||
card.append(title, text);
|
card.append(title, text);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -239,7 +256,7 @@ function renderEngagement(engagement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notificationRoute(item, activeTab) {
|
function notificationRoute(item, activeTab) {
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'events' || activeTab === 'connections') {
|
||||||
const login = String(item?.sourceLogin || '').trim();
|
const login = String(item?.sourceLogin || '').trim();
|
||||||
return login ? makeProfileRoute(login) : '';
|
return login ? makeProfileRoute(login) : '';
|
||||||
}
|
}
|
||||||
@@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) {
|
|||||||
|
|
||||||
const action = document.createElement('p');
|
const action = document.createElement('p');
|
||||||
action.className = 'notification-action';
|
action.className = 'notification-action';
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'connections') {
|
||||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||||
|
} else if (activeTab === 'events') {
|
||||||
|
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||||
} else {
|
} else {
|
||||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||||
}
|
}
|
||||||
@@ -305,90 +324,98 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
tabs.innerHTML = `
|
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
|
||||||
data-tab="replies"
|
|
||||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
>Ответы</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
|
||||||
data-tab="events"
|
|
||||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
>События</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack notifications-list';
|
list.className = 'stack notifications-list';
|
||||||
|
let payloadCache = null;
|
||||||
let requestSeq = 0;
|
let requestSeq = 0;
|
||||||
|
let observer = null;
|
||||||
|
const pendingSeenTimers = { replies: null, connections: null, events: null };
|
||||||
|
const localSeen = { replies: 0, connections: 0, events: 0 };
|
||||||
|
|
||||||
async function load() {
|
function countsFromPayload(payload) {
|
||||||
const seq = ++requestSeq;
|
return {
|
||||||
const activeTab = state.notificationsTab;
|
replies: Number(payload?.repliesUnseenCount || 0),
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
connections: Number(payload?.connectionsUnseenCount || 0),
|
||||||
|
events: Number(payload?.eventsUnseenCount || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function updateToolbarBadge(payload) {
|
||||||
const payload = await authService.getNotifications(50);
|
const c = countsFromPayload(payload);
|
||||||
if (seq !== requestSeq) return;
|
state.notificationUnreadTotal = c.replies + c.connections + c.events;
|
||||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
const btn = document.querySelector('[data-toolbar-page="notifications-view"]');
|
||||||
.map(normalizeItem);
|
if (!btn) return;
|
||||||
if (!baseItems.length) {
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
||||||
return;
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||||
}
|
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
||||||
|
}
|
||||||
|
|
||||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
function renderTabs(payload) {
|
||||||
if (seq !== requestSeq) return;
|
const counts = countsFromPayload(payload);
|
||||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||||
} catch (error) {
|
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
||||||
if (seq !== requestSeq) return;
|
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||||
const card = document.createElement('article');
|
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||||
card.className = 'card stack';
|
return b;
|
||||||
const title = document.createElement('strong');
|
}));
|
||||||
title.textContent = 'Не удалось загрузить уведомления';
|
}
|
||||||
const text = document.createElement('p');
|
|
||||||
text.className = 'meta-muted';
|
function categoryData(payload, tab) {
|
||||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) };
|
||||||
card.append(title, text);
|
if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) };
|
||||||
list.replaceChildren(card);
|
return { items: payload?.replies || [], seenAt: Number(payload?.repliesSeenAtMs || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSeen(category, seenAtMs) {
|
||||||
|
if (seenAtMs <= Number(localSeen[category] || 0)) return;
|
||||||
|
localSeen[category] = seenAtMs;
|
||||||
|
clearTimeout(pendingSeenTimers[category]);
|
||||||
|
pendingSeenTimers[category] = setTimeout(async () => {
|
||||||
|
const target = Number(localSeen[category] || 0);
|
||||||
|
try {
|
||||||
|
await authService.setNotificationSeen({ login: state.session.login, category, seenAtMs: target, storagePwd: state.session.storagePwdInMemory });
|
||||||
|
if (!payloadCache) return;
|
||||||
|
const key = category === 'connections' ? 'connectionsSeenAtMs' : category === 'events' ? 'eventsSeenAtMs' : 'repliesSeenAtMs';
|
||||||
|
const countKey = category === 'connections' ? 'connectionsUnseenCount' : category === 'events' ? 'eventsUnseenCount' : 'repliesUnseenCount';
|
||||||
|
payloadCache[key] = Math.max(Number(payloadCache[key] || 0), target);
|
||||||
|
payloadCache[countKey] = (payloadCache[category] || []).filter(x => Number(x?.createdAtMs || 0) > payloadCache[key]).length;
|
||||||
|
renderTabs(payloadCache); updateToolbarBadge(payloadCache);
|
||||||
|
} catch (e) { console.warn('Не удалось подписать watermark уведомлений', e); }
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrent() {
|
||||||
|
observer?.disconnect(); observer=null; renderTabs(payloadCache || {});
|
||||||
|
const tab=state.notificationsTab; const {items:raw,seenAt}=categoryData(payloadCache || {},tab); localSeen[tab]=Math.max(localSeen[tab]||0,seenAt);
|
||||||
|
const base=raw.map(normalizeItem); if(!base.length){list.replaceChildren(renderEmpty(tab));return;}
|
||||||
|
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
|
||||||
|
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
|
||||||
|
const nodes=[];
|
||||||
|
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
|
||||||
|
let divider=null;
|
||||||
|
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
|
||||||
|
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
|
||||||
|
if(unread.length && 'IntersectionObserver' in window){
|
||||||
|
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
|
||||||
|
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
|
||||||
|
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveNotificationTab(nextTab) {
|
async function load() {
|
||||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab));
|
||||||
state.notificationsTab = normalizedTab;
|
try { payloadCache=await authService.getNotifications(); if(seq!==requestSeq)return; updateToolbarBadge(payloadCache); await renderCurrent(); }
|
||||||
|
catch(error){ if(seq!==requestSeq)return; const card=document.createElement('article');card.className='card stack';card.innerHTML='<strong>Не удалось загрузить уведомления</strong>';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);}
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
|
||||||
const selected = node.dataset.tab === normalizedTab;
|
|
||||||
node.classList.toggle('is-active', selected);
|
|
||||||
node.dataset.selected = selected ? 'true' : 'false';
|
|
||||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies';
|
||||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
screen.cleanup = () => {
|
||||||
setActiveNotificationTab(state.notificationsTab);
|
observer?.disconnect();
|
||||||
|
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
};
|
||||||
btn.addEventListener('click', () => {
|
screen.append(tabs,list);
|
||||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
|
||||||
if (state.notificationsTab === nextTab) {
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.append(tabs, list);
|
|
||||||
void load();
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
PROFILE_GENDER_MALE,
|
PROFILE_GENDER_MALE,
|
||||||
PROFILE_GENDER_UNKNOWN,
|
PROFILE_GENDER_UNKNOWN,
|
||||||
|
PROFILE_ACCOUNT_ROLE_PRIMARY,
|
||||||
|
PROFILE_ACCOUNT_ROLE_NON_VOTING,
|
||||||
|
PROFILE_SHINE_SHINING,
|
||||||
|
PROFILE_SHINE_UNKNOWN,
|
||||||
|
PROFILE_SHINE_NOT_INTERESTED,
|
||||||
loadProfileSnapshot,
|
loadProfileSnapshot,
|
||||||
saveProfileGender,
|
saveProfileGender,
|
||||||
saveProfileParamBlock,
|
saveProfileParamBlock,
|
||||||
saveProfileToggle,
|
saveProfileStatus,
|
||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
|
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
|
||||||
import { openAvatarWizard } from '../components/avatar-wizard.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: 'Редактирование профиля' };
|
export const pageMeta = { id: 'profile-edit-view', title: 'Редактирование профиля' };
|
||||||
|
|
||||||
function toggleText(enabled) {
|
function accountRoleLabel(value) {
|
||||||
return enabled ? 'Yes' : 'No';
|
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) {
|
function showLocalErrorAlert(prefix, error) {
|
||||||
@@ -38,11 +52,9 @@ const GENDER_OPTIONS = Object.freeze([
|
|||||||
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
|
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Родственные типы сохранены в протоколе, но пока намеренно скрыты из UI.
|
||||||
const RELATIVE_RELATION_OPTIONS = Object.freeze([
|
const RELATIVE_RELATION_OPTIONS = Object.freeze([
|
||||||
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
|
{ value: 'friend', label: 'Друг' },
|
||||||
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
|
|
||||||
{ value: 'spouse', label: 'Жена / Муж (по полу)' },
|
|
||||||
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
|
|
||||||
{ value: 'close_friend', label: 'Близкий друг' },
|
{ value: 'close_friend', label: 'Близкий друг' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -75,6 +87,7 @@ function relationAccusativeLabel(type, targetGender) {
|
|||||||
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
|
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
|
||||||
return 'жену/мужа';
|
return 'жену/мужа';
|
||||||
}
|
}
|
||||||
|
if (type === 'friend') return 'друга';
|
||||||
return 'близкого друга';
|
return 'близкого друга';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +133,8 @@ export function render({ navigate, chrome }) {
|
|||||||
const badgesRow = document.createElement('div');
|
const badgesRow = document.createElement('div');
|
||||||
badgesRow.className = 'row';
|
badgesRow.className = 'row';
|
||||||
badgesRow.innerHTML = `
|
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-status="account_role">Аккаунт: Не указано</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="shine">Сияние: Не указано</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('div');
|
const status = document.createElement('div');
|
||||||
@@ -134,22 +147,23 @@ export function render({ navigate, chrome }) {
|
|||||||
const relativesCard = document.createElement('div');
|
const relativesCard = document.createElement('div');
|
||||||
relativesCard.className = 'card stack';
|
relativesCard.className = 'card stack';
|
||||||
relativesCard.innerHTML = `
|
relativesCard.innerHTML = `
|
||||||
<div class="profile-param-value"><b>Близкие родственники</b></div>
|
<div class="profile-param-value"><b>Друзья</b></div>
|
||||||
<div class="meta-muted">
|
<div class="meta-muted">
|
||||||
Добавьте связь: родитель, ребёнок, жена/муж, брат/сестра или близкий друг.
|
Добавьте пользователя в друзья или в близкие друзья.
|
||||||
Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя.
|
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
|
||||||
</div>
|
</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 reloadBtn = topRow.querySelector('[data-reload="true"]');
|
||||||
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
const shineBtn = badgesRow.querySelector('[data-status="shine"]');
|
||||||
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
|
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
|
||||||
const avatarActionEl = topRow.querySelector('[data-change-avatar="true"]');
|
const avatarActionEl = topRow.querySelector('[data-change-avatar="true"]');
|
||||||
|
|
||||||
let currentFields = [];
|
let currentFields = [];
|
||||||
let currentToggles = [];
|
let currentAccountRole = '';
|
||||||
|
let currentShineStatus = '';
|
||||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||||
@@ -234,50 +248,76 @@ export function render({ navigate, chrome }) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateToggleButton(button, prefix, enabled) {
|
function updateStatusesUi() {
|
||||||
button.textContent = `${prefix}: ${toggleText(enabled)}`;
|
if (accountRoleBtn) {
|
||||||
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
|
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
|
||||||
|
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||||
if (!enabled) {
|
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
|
||||||
button.classList.add('is-no');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (shineBtn) {
|
||||||
if (prefix === 'Официальный') {
|
shineBtn.textContent = `Сияние: ${shineStatusLabel(currentShineStatus)}`;
|
||||||
button.classList.add('is-yes-official');
|
shineBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||||
} else {
|
if (currentShineStatus === PROFILE_SHINE_SHINING) shineBtn.classList.add('is-yes-shine');
|
||||||
button.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() {
|
function updateGenderUi() {
|
||||||
const genderValueEl = listWrap.querySelector('[data-gender-value]');
|
const genderValueEl = listWrap.querySelector('[data-gender-value]');
|
||||||
if (!genderValueEl) return;
|
if (!genderValueEl) return;
|
||||||
genderValueEl.textContent = genderLabel(currentGender);
|
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');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return Promise.resolve(null);
|
if (!root) return Promise.resolve(null);
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="profile-field-edit-modal">
|
<div class="modal" id="profile-field-edit-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Изменить: ${escapeHtml(label)}</h3>
|
<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"
|
id="profile-field-edit-input"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="300"
|
maxlength="${Number(maxLength || 300)}"
|
||||||
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
|
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
|
||||||
value="${escapeHtml(String(value || ''))}"
|
value="${escapeHtml(String(value || ''))}"
|
||||||
/>
|
/>`}
|
||||||
|
<div class="meta-muted">До ${Number(maxLength || 300)} символов.</div>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" id="profile-field-edit-cancel" type="button">Отмена</button>
|
<button class="secondary-btn" id="profile-field-edit-cancel" type="button">Отмена</button>
|
||||||
<button class="primary-btn" id="profile-field-edit-save" 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 inputEl = root.querySelector('#profile-field-edit-input');
|
||||||
const saveEl = root.querySelector('#profile-field-edit-save');
|
const saveEl = root.querySelector('#profile-field-edit-save');
|
||||||
const cancelEl = root.querySelector('#profile-field-edit-cancel');
|
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 = '';
|
root.innerHTML = '';
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
@@ -308,7 +348,7 @@ export function render({ navigate, chrome }) {
|
|||||||
cancelEl?.addEventListener('click', () => close(null));
|
cancelEl?.addEventListener('click', () => close(null));
|
||||||
saveEl?.addEventListener('click', () => close(inputEl.value));
|
saveEl?.addEventListener('click', () => close(inputEl.value));
|
||||||
inputEl.addEventListener('keydown', (event) => {
|
inputEl.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (!multiline && event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
close(inputEl.value);
|
close(inputEl.value);
|
||||||
}
|
}
|
||||||
@@ -567,20 +607,21 @@ export function render({ navigate, chrome }) {
|
|||||||
status.className = 'status-line';
|
status.className = 'status-line';
|
||||||
status.textContent = 'Загрузка параметров...';
|
status.textContent = 'Загрузка параметров...';
|
||||||
reloadBtn.disabled = true;
|
reloadBtn.disabled = true;
|
||||||
officialBtn.disabled = true;
|
accountRoleBtn.disabled = true;
|
||||||
shineBtn.disabled = true;
|
shineBtn.disabled = true;
|
||||||
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await loadProfileSnapshot(login);
|
const snapshot = await loadProfileSnapshot(login);
|
||||||
currentFields = snapshot.fields;
|
currentFields = snapshot.fields;
|
||||||
currentToggles = snapshot.toggles;
|
currentAccountRole = snapshot.accountRole || '';
|
||||||
|
currentShineStatus = snapshot.shineStatus || '';
|
||||||
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
||||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||||
|
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
renderFields(currentFields);
|
renderFields(currentFields);
|
||||||
updateTogglesUi();
|
updateStatusesUi();
|
||||||
updateGenderUi();
|
updateGenderUi();
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
|
|
||||||
@@ -592,7 +633,7 @@ export function render({ navigate, chrome }) {
|
|||||||
showLocalErrorAlert('Ошибка загрузки параметров профиля', error);
|
showLocalErrorAlert('Ошибка загрузки параметров профиля', error);
|
||||||
} finally {
|
} finally {
|
||||||
reloadBtn.disabled = false;
|
reloadBtn.disabled = false;
|
||||||
officialBtn.disabled = false;
|
accountRoleBtn.disabled = false;
|
||||||
shineBtn.disabled = false;
|
shineBtn.disabled = false;
|
||||||
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -629,27 +670,32 @@ export function render({ navigate, chrome }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onToggleClick(toggleKey) {
|
async function onStatusClick(statusKey) {
|
||||||
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
|
const isAccountRole = statusKey === 'account_role';
|
||||||
const nextEnabled = !toggle.enabled;
|
const picked = await openStatusPickerModal({
|
||||||
const title = toggleKey === 'official' ? 'официальный' : 'сияющий';
|
title: isAccountRole ? 'Роль аккаунта' : 'Статус сияния',
|
||||||
|
value: isAccountRole ? currentAccountRole : currentShineStatus,
|
||||||
const confirmed = window.confirm(
|
options: isAccountRole
|
||||||
`Хотите изменить «${title}» на ${toggleText(nextEnabled)}?\n` +
|
? [
|
||||||
'Будет создана запись в блокчейне.',
|
{ value: PROFILE_ACCOUNT_ROLE_PRIMARY, label: 'Основной аккаунт' },
|
||||||
);
|
{ value: PROFILE_ACCOUNT_ROLE_NON_VOTING, label: 'Не учитывать мой голос' },
|
||||||
if (!confirmed) return;
|
]
|
||||||
|
: [
|
||||||
|
{ value: PROFILE_SHINE_SHINING, label: 'Сияющий' },
|
||||||
|
{ value: PROFILE_SHINE_UNKNOWN, label: 'Неизвестно' },
|
||||||
|
{ value: PROFILE_SHINE_NOT_INTERESTED, label: 'Сияние мне неинтересно' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!picked) return;
|
||||||
status.className = 'status-line';
|
status.className = 'status-line';
|
||||||
status.textContent = 'Сохранение в блокчейн...';
|
status.textContent = 'Сохранение в блокчейн...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveProfileToggle(login, toggleKey, nextEnabled);
|
await saveProfileStatus(login, statusKey, picked);
|
||||||
await refreshProfileSnapshot();
|
await refreshProfileSnapshot();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.textContent = `Не удалось изменить ${toggleKey}: ${error.message || 'ошибка сети'}`;
|
status.textContent = `Не удалось изменить ${statusKey}: ${error.message || 'ошибка сети'}`;
|
||||||
showLocalErrorAlert(`Ошибка изменения ${toggleKey}`, error);
|
showLocalErrorAlert(`Ошибка изменения ${statusKey}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,8 +707,14 @@ export function render({ navigate, chrome }) {
|
|||||||
label: field.label,
|
label: field.label,
|
||||||
value: field.value || '',
|
value: field.value || '',
|
||||||
placeholder: field.placeholder || '',
|
placeholder: field.placeholder || '',
|
||||||
|
maxLength: field.maxLength || 300,
|
||||||
|
multiline: Boolean(field.multiline),
|
||||||
});
|
});
|
||||||
if (entered === null) return;
|
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.className = 'status-line';
|
||||||
status.textContent = 'Сохранение в блокчейн...';
|
status.textContent = 'Сохранение в блокчейн...';
|
||||||
@@ -753,20 +805,16 @@ export function render({ navigate, chrome }) {
|
|||||||
status.textContent = 'Сохранение связи...';
|
status.textContent = 'Сохранение связи...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (relationType === 'close_friend') {
|
if (!state.session.storagePwdInMemory) {
|
||||||
await authService.addCloseFriend(targetLogin);
|
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
||||||
} else {
|
|
||||||
if (!state.session.storagePwdInMemory) {
|
|
||||||
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
|
||||||
}
|
|
||||||
await authService.setUserRelation({
|
|
||||||
login: sessionLogin,
|
|
||||||
toLogin: targetLogin,
|
|
||||||
kind: relationType,
|
|
||||||
enabled: true,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: sessionLogin,
|
||||||
|
toLogin: targetLogin,
|
||||||
|
kind: relationType,
|
||||||
|
enabled: true,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
status.className = 'status-line is-available';
|
status.className = 'status-line is-available';
|
||||||
status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`;
|
status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -791,8 +839,8 @@ export function render({ navigate, chrome }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
reloadBtn.addEventListener('click', refreshProfileSnapshot);
|
reloadBtn.addEventListener('click', refreshProfileSnapshot);
|
||||||
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
accountRoleBtn.addEventListener('click', () => onStatusClick('account_role'));
|
||||||
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
shineBtn.addEventListener('click', () => onStatusClick('shine'));
|
||||||
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
|
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
|
||||||
avatarActionEl?.addEventListener('click', () => { void onChangeAvatarClick(); });
|
avatarActionEl?.addEventListener('click', () => { void onChangeAvatarClick(); });
|
||||||
|
|
||||||
|
|||||||
+133
-248
@@ -1,27 +1,15 @@
|
|||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
|
||||||
PROFILE_GENDER_MALE,
|
|
||||||
loadProfileSnapshot,
|
loadProfileSnapshot,
|
||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
import { userDisplayName } from '../services/user-display.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
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) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
@@ -31,72 +19,57 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProfileInfoText(text) {
|
function fieldMap(snapshot) {
|
||||||
const [intro, details] = String(text || '').split(/\n\n/, 2);
|
const out = {};
|
||||||
if (!details) {
|
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
|
||||||
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`;
|
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
|
||||||
}
|
});
|
||||||
|
return out;
|
||||||
const [sectionTitle, ...items] = details.split('\n').filter(Boolean);
|
|
||||||
return `
|
|
||||||
<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>
|
|
||||||
<section class="profile-info-modal__section" aria-label="${escapeHtml(sectionTitle)}">
|
|
||||||
<p class="profile-info-modal__section-title">${escapeHtml(sectionTitle)}</p>
|
|
||||||
<ol class="profile-info-modal__list">
|
|
||||||
${items.map((item) => `<li>${escapeHtml(item.replace(/^\d+\)\s*/, ''))}</li>`).join('')}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProfileInfoModal({ title, text }) {
|
function openTextModal(title, text) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal profile-info-modal" id="profile-info-modal" role="dialog" aria-modal="true" aria-labelledby="profile-info-title">
|
<div class="modal" id="profile-text-modal">
|
||||||
<section class="modal-card profile-info-modal__card stack">
|
<div class="modal-card stack">
|
||||||
<header class="profile-info-modal__header">
|
<h3>${escapeHtml(title)}</h3>
|
||||||
<div>
|
<div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text || 'Не заполнено')}</div>
|
||||||
<p class="profile-info-modal__eyebrow">Информация</p>
|
<button class="secondary-btn" id="profile-text-close">Закрыть</button>
|
||||||
<h3 class="modal-title profile-info-modal__title" id="profile-info-title">${escapeHtml(title)}</h3>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
<button class="profile-info-modal__close" type="button" id="profile-info-close-icon" aria-label="Закрыть" title="Закрыть">×</button>
|
|
||||||
</header>
|
|
||||||
<div class="profile-info-modal__content">
|
|
||||||
${renderProfileInfoText(text)}
|
|
||||||
</div>
|
|
||||||
<footer class="profile-info-modal__footer">
|
|
||||||
<button class="secondary-btn profile-info-modal__confirm" type="button" id="profile-info-close">Готово</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const close = () => { root.innerHTML = ''; };
|
const close = () => { root.innerHTML = ''; };
|
||||||
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
root.querySelector('#profile-text-close')?.addEventListener('click', close);
|
||||||
root.querySelector('#profile-info-close-icon')?.addEventListener('click', close);
|
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
|
||||||
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
if (event.target?.id === 'profile-text-modal') close();
|
||||||
if (event.target?.id === 'profile-info-modal') close();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function officialInfoText() {
|
function statusBadges(accountRole, shineStatus) {
|
||||||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
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() {
|
function statsRows(stats = {}) {
|
||||||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
return [
|
||||||
+ 'Пять принципов сияющих:\n'
|
['friends', 'Друзья', stats.friendsCount],
|
||||||
+ '1) сияющие не обманывают;\n'
|
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
|
||||||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
|
||||||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
|
||||||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
|
||||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
|
||||||
|
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
|
||||||
|
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate, chrome }) {
|
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');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack profile-screen';
|
screen.className = 'stack profile-screen';
|
||||||
|
|
||||||
@@ -106,217 +79,129 @@ export function render({ navigate, chrome }) {
|
|||||||
<div class="header-left" aria-hidden="true"></div>
|
<div class="header-left" aria-hidden="true"></div>
|
||||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
<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 type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
|
||||||
</button>
|
</div>`;
|
||||||
</div>
|
const menuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
`;
|
menuButton?.append(createOverflowDots());
|
||||||
|
|
||||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
|
||||||
profileMenuButton?.append(createOverflowDots());
|
|
||||||
const profileMenu = createDropdownMenu({
|
const profileMenu = createDropdownMenu({
|
||||||
anchorEl: profileMenuButton,
|
anchorEl: menuButton,
|
||||||
className: 'profile-head-menu',
|
className: 'profile-head-menu',
|
||||||
minWidth: 230,
|
minWidth: 230,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||||
|
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome?.setTopbar(topbar);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const status = document.createElement('div');
|
||||||
card.className = 'card stack profile-main-card';
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Загрузка профиля...';
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'stack';
|
||||||
|
screen.append(status, body);
|
||||||
|
|
||||||
const topRow = document.createElement('div');
|
let current = null;
|
||||||
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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const badgesRow = document.createElement('div');
|
function renderProfile() {
|
||||||
badgesRow.className = 'row';
|
if (!current) return;
|
||||||
badgesRow.innerHTML = `
|
const { snapshot, user } = current;
|
||||||
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="official">Официальный: No</button>
|
const fields = fieldMap(snapshot);
|
||||||
<button class="badge profile-toggle-btn is-no" type="button" data-toggle="shine">Сияющий: No</button>
|
const firstName = fields.first_name || '';
|
||||||
`;
|
const lastName = fields.last_name || '';
|
||||||
|
const displayName = userDisplayName({ login, firstName, lastName });
|
||||||
|
const avatar = snapshot?.avatar?.txId
|
||||||
|
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
|
: null;
|
||||||
|
const stats = {
|
||||||
|
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||||
|
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||||
|
friendsCount: Number(user?.friendsCount || 0),
|
||||||
|
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||||
|
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
|
||||||
|
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
|
||||||
|
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
|
||||||
|
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
|
||||||
|
};
|
||||||
|
|
||||||
const listWrap = document.createElement('div');
|
body.innerHTML = '';
|
||||||
listWrap.className = 'stack profile-param-list';
|
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 officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const badges = document.createElement('div');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
body.append(...badges.children);
|
||||||
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
|
||||||
|
|
||||||
let currentFields = [];
|
if (fields.about) {
|
||||||
let currentToggles = [];
|
const about = document.createElement('div');
|
||||||
let currentGender = 'unknown';
|
about.className = 'card profile-about';
|
||||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
about.style.whiteSpace = 'pre-wrap';
|
||||||
let currentStats = {
|
about.textContent = fields.about;
|
||||||
ownedPublicChannelsCount: 0,
|
body.append(about);
|
||||||
followingUsersCount: 0,
|
}
|
||||||
followingChannelsCount: 0,
|
|
||||||
closeFriendsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function syncIdentity() {
|
const statsGrid = document.createElement('div');
|
||||||
if (!identityEl) return;
|
statsGrid.className = 'profile-stats-grid';
|
||||||
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
|
statsRows(stats).forEach(([kind, label, value]) => {
|
||||||
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
|
const button = document.createElement('button');
|
||||||
const lines = buildIdentityLines({ login, firstName, lastName });
|
button.type = 'button';
|
||||||
identityEl.innerHTML = lines.map((line, idx) => (
|
button.className = 'card profile-stat-card';
|
||||||
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
|
button.dataset.profileList = kind;
|
||||||
)).join('');
|
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
|
||||||
|
statsGrid.append(button);
|
||||||
|
});
|
||||||
|
body.append(statsGrid);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAvatarUi() {
|
body.addEventListener('click', (event) => {
|
||||||
if (!(avatarSlotEl instanceof HTMLElement)) return;
|
if (!current) return;
|
||||||
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
|
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
|
||||||
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
|
if (!el) return;
|
||||||
avatarSlotEl.innerHTML = '';
|
const kind = el.dataset.profileList;
|
||||||
avatarSlotEl.append(renderUserAvatar({
|
if (kind) {
|
||||||
login,
|
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
if (prefix === 'Официальный') button.classList.add('is-yes-official');
|
const fields = fieldMap(current.snapshot);
|
||||||
else button.classList.add('is-yes-shine');
|
if (el.dataset.profileDetail === 'contacts') {
|
||||||
}
|
openTextModal('Контакты', [
|
||||||
|
fields.web ? `Links: ${fields.web}` : '',
|
||||||
function updateTogglesUi() {
|
fields.phone ? `Телефон: ${fields.phone}` : '',
|
||||||
const official = currentToggles.find((item) => item.key === 'official') || { enabled: false };
|
fields.address ? `Адрес: ${fields.address}` : '',
|
||||||
const shine = currentToggles.find((item) => item.key === 'shine') || { enabled: false };
|
].filter(Boolean).join('\n') || 'Не заполнено');
|
||||||
updateToggleButton(officialBtn, 'Официальный', official.enabled);
|
}
|
||||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async function refresh() {
|
||||||
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) {
|
if (state.session.isLocalDemo) {
|
||||||
currentFields = [
|
status.textContent = 'Локальный тестовый режим.';
|
||||||
{ 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;
|
return;
|
||||||
}
|
}
|
||||||
|
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
|
||||||
try {
|
current = { snapshot, user };
|
||||||
const [snapshot, user] = await Promise.all([
|
renderProfile();
|
||||||
loadProfileSnapshot(login),
|
status.textContent = '';
|
||||||
authService.getUser(login).catch(() => ({})),
|
|
||||||
]);
|
|
||||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
|
||||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
|
||||||
currentGender = snapshot.gender || 'unknown';
|
|
||||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
|
||||||
currentStats = {
|
|
||||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
|
||||||
followingUsersCount: Number(user?.followingUsersCount || 0),
|
|
||||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
|
||||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
|
||||||
};
|
|
||||||
syncIdentity();
|
|
||||||
updateAvatarUi();
|
|
||||||
updateTogglesUi();
|
|
||||||
renderFields(currentFields);
|
|
||||||
} catch (error) {
|
|
||||||
// ignore status row in profile-view
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
card.append(topRow, badgesRow, listWrap);
|
refresh().catch((error) => {
|
||||||
screen.append(card);
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||||
updateAvatarUi();
|
});
|
||||||
refreshProfileSnapshot();
|
|
||||||
|
|
||||||
screen.cleanup = () => profileMenu.destroy();
|
screen.cleanup = () => profileMenu.destroy();
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import {
|
||||||
|
closeAllSavedProfiles,
|
||||||
|
closeSavedProfile,
|
||||||
|
getSavedProfiles,
|
||||||
|
prepareAddProfileLogin,
|
||||||
|
state,
|
||||||
|
switchToSavedProfile,
|
||||||
|
} from '../state.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id: 'profiles-view', title: 'Профили' };
|
||||||
|
|
||||||
|
function reloadTo(path) {
|
||||||
|
const clean = String(path || '/profile').trim() || '/profile';
|
||||||
|
window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate }) {
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'stack profiles-screen';
|
||||||
|
|
||||||
|
screen.append(renderHeader({
|
||||||
|
title: 'Профили',
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const intro = document.createElement('div');
|
||||||
|
intro.className = 'meta-muted profiles-summary';
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'stack profiles-list';
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.hidden = true;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack profiles-actions';
|
||||||
|
|
||||||
|
const addButton = document.createElement('button');
|
||||||
|
addButton.type = 'button';
|
||||||
|
addButton.className = 'secondary-btn';
|
||||||
|
addButton.textContent = 'Добавить профиль';
|
||||||
|
addButton.addEventListener('click', async () => {
|
||||||
|
addButton.disabled = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = 'Подготавливаем вход в новый профиль…';
|
||||||
|
try {
|
||||||
|
await prepareAddProfileLogin();
|
||||||
|
navigate('login-view');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось начать добавление профиля: ${error?.message || 'unknown'}`;
|
||||||
|
addButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeAllButton = document.createElement('button');
|
||||||
|
closeAllButton.type = 'button';
|
||||||
|
closeAllButton.className = 'secondary-btn profiles-close-all';
|
||||||
|
closeAllButton.textContent = 'Закрыть все профили';
|
||||||
|
closeAllButton.addEventListener('click', async () => {
|
||||||
|
const profiles = getSavedProfiles();
|
||||||
|
if (!profiles.length) return;
|
||||||
|
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
|
||||||
|
if (!confirmed) return;
|
||||||
|
closeAllButton.disabled = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = 'Закрываем профили…';
|
||||||
|
try {
|
||||||
|
await closeAllSavedProfiles();
|
||||||
|
reloadTo('/start');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось закрыть профили: ${error?.message || 'unknown'}`;
|
||||||
|
closeAllButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(addButton, closeAllButton);
|
||||||
|
screen.append(intro, list, status, actions);
|
||||||
|
|
||||||
|
const renderList = () => {
|
||||||
|
const profiles = getSavedProfiles();
|
||||||
|
const active = profiles.find((item) => item.isActive);
|
||||||
|
intro.textContent = profiles.length
|
||||||
|
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
|
||||||
|
: 'На устройстве нет сохранённых профилей.';
|
||||||
|
closeAllButton.disabled = profiles.length === 0;
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
profiles.forEach((profile) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = `card profiles-row${profile.isActive ? ' is-active' : ''}`;
|
||||||
|
|
||||||
|
const select = document.createElement('button');
|
||||||
|
select.type = 'button';
|
||||||
|
select.className = 'profiles-select';
|
||||||
|
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||||
|
select.disabled = profile.isActive;
|
||||||
|
select.addEventListener('click', async () => {
|
||||||
|
if (profile.isActive) return;
|
||||||
|
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = `Подключаем профиль ${profile.login}…`;
|
||||||
|
try {
|
||||||
|
await switchToSavedProfile(profile.login);
|
||||||
|
reloadTo('/profile');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось переключить профиль: ${error?.message || 'unknown'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.className = 'profiles-close';
|
||||||
|
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
|
||||||
|
close.textContent = '×';
|
||||||
|
close.addEventListener('click', async () => {
|
||||||
|
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
|
||||||
|
const message = profile.isActive
|
||||||
|
? (others.length
|
||||||
|
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
|
||||||
|
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
|
||||||
|
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
|
||||||
|
if (!window.confirm(message)) return;
|
||||||
|
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status-line';
|
||||||
|
status.textContent = `Закрываем профиль ${profile.login}…`;
|
||||||
|
try {
|
||||||
|
const result = await closeSavedProfile(profile.login);
|
||||||
|
if (profile.isActive) {
|
||||||
|
reloadTo(result.nextProfile ? '/profile' : '/start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.hidden = true;
|
||||||
|
renderList();
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = `Не удалось закрыть профиль: ${error?.message || 'unknown'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(select, close);
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
|
cancelAddProfileLogin,
|
||||||
|
consumeAuthReturnPage,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
resetRegistrationFlow,
|
resetRegistrationFlow,
|
||||||
@@ -103,7 +105,12 @@ export function render({ navigate }) {
|
|||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => {
|
cancelButton.addEventListener('click', async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetRegistrationFlow();
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
@@ -143,7 +150,7 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||||
|
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
login: state.registrationDraft.login,
|
login: state.registrationDraft.login,
|
||||||
@@ -174,13 +181,7 @@ export function render({ navigate }) {
|
|||||||
setAuthInfo(isLoginFlow
|
setAuthInfo(isLoginFlow
|
||||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||||
const nextHash = String(state.authReturnHash || '').trim();
|
navigate(consumeAuthReturnPage('profile-view'));
|
||||||
state.authReturnHash = '';
|
|
||||||
if (nextHash.startsWith('/')) {
|
|
||||||
navigate(nextHash.slice(1));
|
|
||||||
} else {
|
|
||||||
navigate('profile-view');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
@@ -196,7 +197,12 @@ export function render({ navigate }) {
|
|||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: {
|
leftAction: {
|
||||||
label: '←',
|
label: '←',
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
|
if (state.authReturnHash === '/profiles') {
|
||||||
|
await cancelAddProfileLogin();
|
||||||
|
navigate('profiles-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetRegistrationFlow();
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
|
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
|
||||||
|
|
||||||
const resumed = await authService.resumeSession(result.login, result.sessionId);
|
const resumed = await authService.resumeSession(result.login, result.sessionId);
|
||||||
const resumedLogin = resumed.login || result.login;
|
const resumedLogin = resumed.login || result.login;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
|
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function render({ navigate }) {
|
|||||||
const signOutBtn = card.querySelector('#settings-signout');
|
const signOutBtn = card.querySelector('#settings-signout');
|
||||||
signOutBtn.addEventListener('click', async () => {
|
signOutBtn.addEventListener('click', async () => {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
|
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -88,9 +88,8 @@ export function render({ navigate }) {
|
|||||||
source: 'session',
|
source: 'session',
|
||||||
message: 'Запрошено завершение текущей сессии',
|
message: 'Запрошено завершение текущей сессии',
|
||||||
});
|
});
|
||||||
await closeCurrentSessionAndSignOut({
|
const result = await closeSavedProfile(state.session.login);
|
||||||
infoMessage: 'Сеанс завершён. Выполните вход заново.',
|
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
signOutBtn.disabled = false;
|
signOutBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, state } from '../state.js';
|
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
|
||||||
import {
|
|
||||||
buildIdentityLines,
|
|
||||||
loadRelationsForPair,
|
|
||||||
loadUserProfileCard,
|
|
||||||
} from '../services/user-connections.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { authService, state } from '../state.js';
|
||||||
|
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
|
export const pageMeta = { id: 'user', title: 'Профиль' };
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
@@ -21,488 +17,331 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProfileInfoModal({ title, text }) {
|
function effectiveSocial(flags = {}) {
|
||||||
const root = document.getElementById('modal-root');
|
if (flags.outCloseFriend) return 'close_friend';
|
||||||
if (!root) return;
|
if (flags.outFriend) return 'friend';
|
||||||
root.innerHTML = `
|
if (flags.outContact) return 'contact';
|
||||||
<div class="modal" id="profile-info-modal">
|
return 'none';
|
||||||
<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>
|
|
||||||
</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() {
|
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
|
||||||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
const numericValue = Number(value || 0);
|
||||||
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shineInfoText() {
|
|
||||||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
|
||||||
+ 'Пять принципов сияющих:\n'
|
|
||||||
+ '1) сияющие не обманывают;\n'
|
|
||||||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
|
||||||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
|
||||||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
|
||||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
login: card.login,
|
|
||||||
firstName: card.firstName,
|
|
||||||
lastName: card.lastName,
|
|
||||||
avatar: card.avatar,
|
|
||||||
size: 'xl',
|
|
||||||
className: 'profile-avatar',
|
|
||||||
}));
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReadOnlyBadges(card) {
|
|
||||||
return `
|
return `
|
||||||
<div class="row wrap-row">
|
<button
|
||||||
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
|
type="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>
|
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
|
||||||
</div>
|
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 renderRelations(flags) {
|
function spiritualPathDetailHtml(card) {
|
||||||
|
const value = String(card?.spiritualPath || '').trim();
|
||||||
|
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactsDetailHtml(card) {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
|
['Ссылки', card?.web],
|
||||||
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
|
['Телефон', card?.phone],
|
||||||
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
|
['Адрес', card?.address],
|
||||||
];
|
].filter(([, value]) => String(value || '').trim());
|
||||||
const opinionItems = opinionItemsFromFlags(flags);
|
|
||||||
const hasOpinion = opinionItems.length > 0;
|
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
|
||||||
|
}
|
||||||
|
return rows.map(([label, value]) => `
|
||||||
|
<div class="user-profile-contact-row">
|
||||||
|
<span>${escapeHtml(label)}</span>
|
||||||
|
<b>${escapeHtml(value)}</b>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIconHtml() {
|
||||||
return `
|
return `
|
||||||
<div class="card stack user-relations-list" data-profile-relations="true">
|
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||||
${rows.map((row) => `
|
<path d="M10.5 20.5 17 27l13-14" />
|
||||||
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
</svg>`;
|
||||||
<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 }) {
|
function relationMenuHtml(flags = {}) {
|
||||||
const root = document.getElementById('modal-root');
|
const current = effectiveSocial(flags);
|
||||||
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 = [
|
const rows = [
|
||||||
{ label: 'Имя', value: card.firstName },
|
['friend', 'Друг'],
|
||||||
{ label: 'Фамилия', value: card.lastName },
|
['close_friend', 'Близкий друг'],
|
||||||
{ label: 'Пол', value: genderText(card.gender) },
|
['contact', 'Контакт'],
|
||||||
{ label: 'Адрес', value: card.address },
|
|
||||||
{ label: 'Web', value: card.web },
|
|
||||||
{ label: 'Телефон', value: card.phone },
|
|
||||||
];
|
];
|
||||||
|
return rows.map(([kind, label]) => `
|
||||||
return `
|
<button type="button" class="user-profile-add-option${current === kind ? ' is-current' : ''}" data-relation-kind="${kind}">
|
||||||
<div class="card stack profile-param-list">
|
<span>${escapeHtml(label)}</span>
|
||||||
${rows.map((row) => `
|
<span class="user-profile-add-option-check" aria-hidden="true">${current === kind ? '✓' : ''}</span>
|
||||||
<div class="card profile-param-item row">
|
</button>`).join('');
|
||||||
<div class="profile-param-value"><b>${row.label}</b>: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route, chrome }) {
|
||||||
const requestedLogin = String(route.params.login || '').trim();
|
const requestedLogin = String(route?.params?.login || '').trim();
|
||||||
const sessionLogin = String(state.session.login || '').trim();
|
const selfLogin = String(state.session.login || '').trim();
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
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');
|
const status = document.createElement('div');
|
||||||
status.className = 'status-line';
|
status.className = 'status-line user-profile-status';
|
||||||
status.textContent = 'Загрузка профиля...';
|
status.textContent = 'Загрузка профиля...';
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'stack';
|
body.className = 'user-profile-body';
|
||||||
|
screen.append(status, body);
|
||||||
|
|
||||||
screen.append(
|
let card = null;
|
||||||
renderHeader({
|
let relationFlags = null;
|
||||||
title: 'Профиль пользователя',
|
let relationLoadPromise = null;
|
||||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
let addMenu = null;
|
||||||
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
let addActionButton = null;
|
||||||
}),
|
|
||||||
status,
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
|
||||||
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
|
||||||
|
|
||||||
let currentCard = null;
|
function updateRelationUi() {
|
||||||
let currentFlags = null;
|
if (!addMenu) return;
|
||||||
let isBusy = false;
|
addMenu.innerHTML = relationMenuHtml(relationFlags || {});
|
||||||
|
const social = effectiveSocial(relationFlags || {});
|
||||||
function syncActionButtons() {
|
addActionButton?.classList.toggle('is-active', social !== 'none');
|
||||||
const followBtn = body.querySelector('[data-relation-action="follow"]');
|
addActionButton?.setAttribute('aria-label', social === 'none' ? 'Добавить' : 'Изменить связь');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function ensureRelationFlags() {
|
||||||
if (!requestedLogin) {
|
if (relationFlags) return relationFlags;
|
||||||
status.className = 'status-line is-unavailable';
|
if (relationLoadPromise) return relationLoadPromise;
|
||||||
status.textContent = 'Не передан login пользователя.';
|
if (!selfLogin || !card?.login) return {};
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
isBusy = true;
|
relationFlags = await loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login });
|
||||||
status.className = 'status-line';
|
updateRelationUi();
|
||||||
status.textContent = 'Загрузка профиля...';
|
}
|
||||||
|
|
||||||
try {
|
function renderProfile() {
|
||||||
const card = await loadUserProfileCard(requestedLogin);
|
if (!card) return;
|
||||||
const flags = await loadRelationsForPair({
|
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
|
||||||
currentLogin: sessionLogin,
|
const stats = card.stats || {};
|
||||||
targetLogin: card.login,
|
const official = card.accountRole === 'primary';
|
||||||
});
|
const shining = card.shineStatus === 'shining';
|
||||||
|
const fullName = [card.firstName, card.lastName]
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const about = String(card.about || '').trim();
|
||||||
|
|
||||||
currentCard = card;
|
const title = header.querySelector('.page-title');
|
||||||
currentFlags = flags;
|
if (title) title.textContent = card.login;
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
${renderReadOnlyBadges(card)}
|
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
|
||||||
${renderRelations(flags)}
|
|
||||||
${renderReadOnlyParams(card)}
|
|
||||||
`;
|
|
||||||
const identityCard = document.createElement('div');
|
|
||||||
identityCard.className = 'card stack';
|
|
||||||
identityCard.append(renderIdentity(card));
|
|
||||||
body.prepend(identityCard);
|
|
||||||
|
|
||||||
syncActionButtons();
|
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
|
||||||
if (String(route?.params?.section || '').toLowerCase() === 'links') {
|
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
|
||||||
const rel = body.querySelector('[data-profile-relations="true"]');
|
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
|
||||||
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
<div class="user-profile-avatar-slot"></div>
|
||||||
}
|
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
|
||||||
status.className = 'status-line is-available';
|
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
|
||||||
status.textContent = 'Профиль обновлён.';
|
</div>
|
||||||
} catch (error) {
|
|
||||||
status.className = 'status-line is-unavailable';
|
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
|
||||||
status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
|
|
||||||
window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
|
<div class="user-profile-channel-metrics">
|
||||||
} finally {
|
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
|
||||||
isBusy = false;
|
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!isSelf ? `
|
||||||
|
<div class="user-profile-actions-wrap">
|
||||||
|
<div class="user-profile-add-menu" hidden></div>
|
||||||
|
<div class="user-profile-actions" aria-label="Действия с пользователем">
|
||||||
|
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">
|
||||||
|
${addIconHtml()}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
|
||||||
|
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
|
||||||
|
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
|
||||||
|
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
|
||||||
|
<span class="user-profile-detail-tab-label">Духовный путь</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
|
||||||
|
<span class="user-profile-detail-tab-label">Контакты</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>`;
|
||||||
|
|
||||||
|
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||||
|
avatarSlot?.append(renderUserAvatar({
|
||||||
|
login: card.login,
|
||||||
|
firstName: card.firstName,
|
||||||
|
lastName: card.lastName,
|
||||||
|
avatar: card.avatar,
|
||||||
|
size: 'xl',
|
||||||
|
className: 'user-profile-hero-avatar',
|
||||||
|
glow: shining,
|
||||||
|
}));
|
||||||
|
|
||||||
|
addMenu = body.querySelector('.user-profile-add-menu');
|
||||||
|
addActionButton = body.querySelector('[data-profile-action="add"]');
|
||||||
|
updateRelationUi();
|
||||||
|
status.textContent = '';
|
||||||
|
|
||||||
|
if (!isSelf && selfLogin) {
|
||||||
|
void ensureRelationFlags().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRelationAction(kind) {
|
body.addEventListener('click', async (event) => {
|
||||||
if (isBusy || !currentCard || !currentFlags) return;
|
if (!card) return;
|
||||||
if (!sessionLogin) {
|
const listButton = event.target.closest('[data-profile-list]');
|
||||||
window.alert('Для изменения связей нужен активный вход.');
|
if (listButton) {
|
||||||
return;
|
const kind = listButton.dataset.profileList;
|
||||||
}
|
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
|
||||||
if (!state.session.storagePwdInMemory) {
|
|
||||||
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'opinion-menu') {
|
const detailButton = event.target.closest('[data-profile-detail]');
|
||||||
openOpinionMenuModal({
|
if (detailButton) {
|
||||||
flags: currentFlags,
|
const detailKind = detailButton.dataset.profileDetail;
|
||||||
onApply: onOpinionApply,
|
const detailPanel = body.querySelector('#user-profile-detail-panel');
|
||||||
|
if (!detailPanel) return;
|
||||||
|
|
||||||
|
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
|
||||||
|
const active = button === detailButton;
|
||||||
|
button.classList.toggle('is-active', active);
|
||||||
|
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextEnabled = relationNextState(kind, currentFlags);
|
if (detailKind === 'spiritual-path') {
|
||||||
const confirmed = window.confirm(
|
detailPanel.innerHTML = spiritualPathDetailHtml(card);
|
||||||
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
|
} else if (detailKind === 'contacts') {
|
||||||
'Будет отправлен AddBlock CONNECTION.',
|
detailPanel.innerHTML = contactsDetailHtml(card);
|
||||||
);
|
} else {
|
||||||
if (!confirmed) return;
|
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') {
|
detailPanel.hidden = false;
|
||||||
await authService.setUserRelation({
|
detailPanel.dataset.activeDetail = detailKind;
|
||||||
login: sessionLogin,
|
return;
|
||||||
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 relationButton = event.target.closest('[data-relation-kind]');
|
||||||
const target = event.target;
|
if (relationButton) {
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!selfLogin) {
|
||||||
const infoBtn = target.closest('[data-profile-info]');
|
status.className = 'status-line user-profile-status is-unavailable';
|
||||||
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
|
status.textContent = 'Для добавления пользователя необходимо войти.';
|
||||||
if (infoKind === 'official') {
|
return;
|
||||||
openProfileInfoModal({
|
}
|
||||||
title: 'Официальный канал',
|
const next = relationButton.dataset.relationKind;
|
||||||
text: officialInfoText(),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (infoKind === 'shine') {
|
|
||||||
openProfileInfoModal({
|
const actionButton = event.target.closest('[data-profile-action]');
|
||||||
title: 'Справка о сияющих',
|
const action = actionButton?.dataset.profileAction;
|
||||||
text: shineInfoText(),
|
if (action === 'add') {
|
||||||
});
|
if (!addMenu) return;
|
||||||
|
navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const actionBtn = target.closest('[data-relation-action]');
|
if (action === 'links') {
|
||||||
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
navigate(makeProfileLinksRoute(card.login));
|
||||||
if (!kind) return;
|
return;
|
||||||
void onRelationAction(kind);
|
}
|
||||||
|
if (action === 'chat') {
|
||||||
|
navigate(`chat/${encodeURIComponent(card.login)}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
refresh();
|
const handleOutsidePointer = (event) => {
|
||||||
|
if (!addMenu || addMenu.hidden) return;
|
||||||
|
if (event.target instanceof Node && body.contains(event.target)) {
|
||||||
|
const insideActions = event.target.closest?.('.user-profile-actions-wrap');
|
||||||
|
if (insideActions) return;
|
||||||
|
}
|
||||||
|
addMenu.hidden = true;
|
||||||
|
addActionButton?.setAttribute('aria-expanded', 'false');
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handleOutsidePointer);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
card = await loadUserProfileCard(requestedLogin);
|
||||||
|
renderProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh().catch((error) => {
|
||||||
|
status.className = 'status-line user-profile-status is-unavailable';
|
||||||
|
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.cleanup = () => {
|
||||||
|
document.removeEventListener('pointerdown', handleOutsidePointer);
|
||||||
|
};
|
||||||
|
|
||||||
return screen;
|
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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const PRETTY_PATHS = new Map([
|
|||||||
['key-storage-view', 'key-storage'],
|
['key-storage-view', 'key-storage'],
|
||||||
['profile-view', 'profile'],
|
['profile-view', 'profile'],
|
||||||
['profile-edit-view', 'profile/edit'],
|
['profile-edit-view', 'profile/edit'],
|
||||||
|
['profiles-view', 'profiles'],
|
||||||
['messages-list', 'messages'],
|
['messages-list', 'messages'],
|
||||||
['contact-search-view', 'contacts'],
|
['contact-search-view', 'contacts'],
|
||||||
['chat-view', 'chat'],
|
['chat-view', 'chat'],
|
||||||
@@ -107,6 +108,12 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
if (section === 'links') {
|
if (section === 'links') {
|
||||||
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
|
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') {
|
if (section === 'channels') {
|
||||||
const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
|
const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase();
|
||||||
if (sub === 'owned') {
|
if (sub === 'owned') {
|
||||||
@@ -242,6 +249,10 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
return { pageId: 'profile-view', params: {} };
|
return { pageId: 'profile-view', params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'profiles') {
|
||||||
|
return { pageId: 'profiles-view', params: {} };
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'messages') {
|
if (pageId === 'messages') {
|
||||||
return { pageId: 'messages-list', params: {} };
|
return { pageId: 'messages-list', params: {} };
|
||||||
}
|
}
|
||||||
@@ -431,6 +442,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
) return pageId;
|
) return pageId;
|
||||||
if (
|
if (
|
||||||
pageId === 'profile-edit-view' ||
|
pageId === 'profile-edit-view' ||
|
||||||
|
pageId === 'profiles-view' ||
|
||||||
pageId === 'wallet-view' ||
|
pageId === 'wallet-view' ||
|
||||||
pageId === 'settings-view' ||
|
pageId === 'settings-view' ||
|
||||||
pageId === 'access-servers-view' ||
|
pageId === 'access-servers-view' ||
|
||||||
|
|||||||
@@ -90,18 +90,21 @@ const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
|
|||||||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
||||||
|
|
||||||
const CONNECTION_SUBTYPES = Object.freeze({
|
const CONNECTION_SUBTYPES = Object.freeze({
|
||||||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
|
||||||
close_friend: { on: 10, off: 11 },
|
close_friend: { on: 10, off: 11 },
|
||||||
friend: { on: 10, off: 11 },
|
friend: { on: 14, off: 15 },
|
||||||
contact: { on: 20, off: 21 },
|
contact: { on: 20, off: 21 },
|
||||||
|
// Reserved: пока не используется UI.
|
||||||
follow: { on: 30, off: 31 },
|
follow: { on: 30, off: 31 },
|
||||||
|
// Reserved: семейные связи пока скрыты из UI.
|
||||||
spouse: { on: 40, off: 41 },
|
spouse: { on: 40, off: 41 },
|
||||||
parent: { on: 50, off: 51 },
|
parent: { on: 50, off: 51 },
|
||||||
child: { on: 52, off: 53 },
|
child: { on: 52, off: 53 },
|
||||||
sibling: { on: 54, off: 55 },
|
sibling: { on: 54, off: 55 },
|
||||||
known_person: { on: 60, off: 61 },
|
// Legacy 60/61 intentionally absent: старые блоки сервер принимает, новый UI их не создаёт.
|
||||||
shine_confirmed: { on: 70, off: 71 },
|
shine_confirmed: { on: 70, off: 71 },
|
||||||
|
// Reserved: пока не используется UI.
|
||||||
shine_seen: { on: 74, off: 75 },
|
shine_seen: { on: 74, off: 75 },
|
||||||
|
official_account: { on: 80, off: 81 },
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeServerUrl(url) {
|
function normalizeServerUrl(url) {
|
||||||
@@ -246,6 +249,11 @@ function uint8Bytes(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||||
|
const NTF_PREFIX_V1 = utf8Bytes('SHiNE_NTF');
|
||||||
|
const NTF_FORMAT_VERSION_MAJOR = 1;
|
||||||
|
const NTF_FORMAT_VERSION_MINOR = 0;
|
||||||
|
const NTF_STATE_SEEN_WATERMARK = 1;
|
||||||
|
const NTF_CATEGORY = { replies: 1, connections: 2, events: 3 };
|
||||||
const DM_TYPE_INCOMING = 1;
|
const DM_TYPE_INCOMING = 1;
|
||||||
const DM_TYPE_OUTGOING_COPY = 2;
|
const DM_TYPE_OUTGOING_COPY = 2;
|
||||||
const DM_TYPE_READ_INCOMING = 3;
|
const DM_TYPE_READ_INCOMING = 3;
|
||||||
@@ -991,6 +999,8 @@ export class AuthService {
|
|||||||
constructor(serverUrl) {
|
constructor(serverUrl) {
|
||||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
this.ws = new WsJsonClient(this.serverUrl);
|
||||||
|
this.eventListeners = new Map();
|
||||||
|
this.wsEventUnsubscribers = new Map();
|
||||||
this.headerHashCache = new Map();
|
this.headerHashCache = new Map();
|
||||||
this.writeLocks = new Map();
|
this.writeLocks = new Map();
|
||||||
this.passwordKeyBundleCache = new Map();
|
this.passwordKeyBundleCache = new Map();
|
||||||
@@ -1000,14 +1010,39 @@ export class AuthService {
|
|||||||
this.remoteAddBlockSessionId = '';
|
this.remoteAddBlockSessionId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconnect(serverUrl) {
|
bindRegisteredEventsToCurrentWs() {
|
||||||
|
this.wsEventUnsubscribers.forEach((unsubscribe) => {
|
||||||
|
try { unsubscribe?.(); } catch {}
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.clear();
|
||||||
|
|
||||||
|
this.eventListeners.forEach((_handlers, op) => {
|
||||||
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
if (!handlers) return;
|
||||||
|
handlers.forEach((handler) => {
|
||||||
|
try { handler(data); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetConnection(serverUrl = this.serverUrl, { clearSessionContext = true } = {}) {
|
||||||
const normalized = normalizeServerUrl(serverUrl);
|
const normalized = normalizeServerUrl(serverUrl);
|
||||||
if (normalized === this.serverUrl) return;
|
try { this.ws?.close(); } catch {}
|
||||||
this.ws.close();
|
|
||||||
this.serverUrl = normalized;
|
this.serverUrl = normalized;
|
||||||
this.ws = new WsJsonClient(this.serverUrl);
|
this.ws = new WsJsonClient(this.serverUrl);
|
||||||
this.headerHashCache = new Map();
|
this.headerHashCache = new Map();
|
||||||
this.writeLocks.clear();
|
this.writeLocks.clear();
|
||||||
|
this.bindRegisteredEventsToCurrentWs();
|
||||||
|
if (clearSessionContext) this.clearActiveSessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(serverUrl) {
|
||||||
|
const normalized = normalizeServerUrl(serverUrl);
|
||||||
|
if (normalized === this.serverUrl) return;
|
||||||
|
this.resetConnection(normalized, { clearSessionContext: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
||||||
@@ -2506,7 +2541,28 @@ export class AuthService {
|
|||||||
|
|
||||||
|
|
||||||
onEvent(op, handler) {
|
onEvent(op, handler) {
|
||||||
return this.ws.onEvent(op, handler);
|
if (!op || typeof handler !== 'function') return () => {};
|
||||||
|
if (!this.eventListeners.has(op)) {
|
||||||
|
this.eventListeners.set(op, new Set());
|
||||||
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
if (!handlers) return;
|
||||||
|
handlers.forEach((callback) => {
|
||||||
|
try { callback(data); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
||||||
|
}
|
||||||
|
const handlers = this.eventListeners.get(op);
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size) return;
|
||||||
|
this.eventListeners.delete(op);
|
||||||
|
const unsubscribe = this.wsEventUnsubscribers.get(op);
|
||||||
|
try { unsubscribe?.(); } catch {}
|
||||||
|
this.wsEventUnsubscribers.delete(op);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
||||||
@@ -2888,20 +2944,58 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotifications(limit = 50) {
|
async getNotifications(countsOnly = false) {
|
||||||
const payload = {};
|
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
||||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
|
||||||
const response = await this.ws.request('GetNotifications', payload);
|
|
||||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setNotificationSeen({ login, category, seenAtMs, storagePwd }) {
|
||||||
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
|
const cleanCategory = String(category || '').trim().toLowerCase();
|
||||||
|
const categoryCode = NTF_CATEGORY[cleanCategory];
|
||||||
|
if (!cleanLogin || !categoryCode) throw new Error('Некорректный login/category уведомлений');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи состояния уведомлений');
|
||||||
|
const normalizedSeenAtMs = Math.max(0, Math.trunc(Number(seenAtMs || 0)));
|
||||||
|
const timeMs = Date.now();
|
||||||
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const clientPriv = secrets?.clientKey;
|
||||||
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||||
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||||
|
const loginBytes = ensureAsciiBytes(cleanLogin, 'login');
|
||||||
|
const preimage = concatBytes(
|
||||||
|
NTF_PREFIX_V1,
|
||||||
|
uint8Bytes(NTF_FORMAT_VERSION_MAJOR), uint8Bytes(NTF_FORMAT_VERSION_MINOR),
|
||||||
|
uint8Bytes(loginBytes.length), loginBytes,
|
||||||
|
uint64Bytes(timeMs), uint32Bytes(nonce),
|
||||||
|
uint8Bytes(NTF_STATE_SEEN_WATERMARK), uint8Bytes(categoryCode),
|
||||||
|
uint64Bytes(normalizedSeenAtMs),
|
||||||
|
);
|
||||||
|
const signature = await signBytes(privateKey, preimage);
|
||||||
|
const response = await this.ws.request('SetNotificationState', { blobB64: bytesToBase64(concatBytes(preimage, signature)) });
|
||||||
|
if (response.status !== 200) throw opError('SetNotificationState', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getUserConnectionsGraph(login) {
|
async getUserConnectionsGraph(login) {
|
||||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||||
return response.payload || {};
|
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 = {}) {
|
async searchUsers(prefix, options = {}) {
|
||||||
const payload = { prefix };
|
const payload = { prefix };
|
||||||
if (typeof options?.isServer === 'boolean') {
|
if (typeof options?.isServer === 'boolean') {
|
||||||
|
|||||||
@@ -1,16 +1,67 @@
|
|||||||
const DB_NAME = 'shine-ui-messages-v1';
|
const DB_NAME = 'shine-ui-messages-v1';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 3;
|
||||||
const STORE_MESSAGES = 'messages';
|
const STORE_MESSAGES = 'messages_by_profile';
|
||||||
|
const LEGACY_STORE_MESSAGES = 'messages';
|
||||||
|
const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
|
||||||
|
const LEGACY_SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
|
||||||
|
function normalizeOwnerLogin(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(ownerLogin, messageKey) {
|
||||||
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
|
const key = String(messageKey || '').trim();
|
||||||
|
return owner && key ? `${owner}|${key}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationOwnerLogin() {
|
||||||
|
try {
|
||||||
|
const active = normalizeOwnerLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
|
||||||
|
if (active) return active;
|
||||||
|
const legacy = JSON.parse(localStorage.getItem(LEGACY_SESSION_STORAGE_KEY) || '{}');
|
||||||
|
return normalizeOwnerLogin(legacy?.login);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndexes(store) {
|
||||||
|
if (!store.indexNames.contains('by_chat')) store.createIndex('by_chat', 'chatId', { unique: false });
|
||||||
|
if (!store.indexNames.contains('by_ts')) store.createIndex('by_ts', 'ts', { unique: false });
|
||||||
|
if (!store.indexNames.contains('by_owner')) store.createIndex('by_owner', 'ownerLogin', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
function openDb() {
|
function openDb() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
|
let store;
|
||||||
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||||
const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
|
store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'storageKey' });
|
||||||
store.createIndex('by_chat', 'chatId', { unique: false });
|
} else {
|
||||||
store.createIndex('by_ts', 'ts', { unique: false });
|
store = request.transaction.objectStore(STORE_MESSAGES);
|
||||||
|
}
|
||||||
|
ensureIndexes(store);
|
||||||
|
|
||||||
|
// Однократная миграция старого single-profile кэша в пространство текущего профиля.
|
||||||
|
if (db.objectStoreNames.contains(LEGACY_STORE_MESSAGES)) {
|
||||||
|
const owner = migrationOwnerLogin();
|
||||||
|
if (owner) {
|
||||||
|
const legacy = request.transaction.objectStore(LEGACY_STORE_MESSAGES);
|
||||||
|
const cursorReq = legacy.openCursor();
|
||||||
|
cursorReq.onsuccess = () => {
|
||||||
|
const cursor = cursorReq.result;
|
||||||
|
if (!cursor) return;
|
||||||
|
const row = cursor.value || {};
|
||||||
|
const messageKey = String(row.messageKey || '').trim();
|
||||||
|
const rowOwner = normalizeOwnerLogin(row.ownerLogin) || owner;
|
||||||
|
const key = storageKey(rowOwner, messageKey);
|
||||||
|
if (key) store.put({ ...row, ownerLogin: rowOwner, storageKey: key });
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -36,31 +87,55 @@ async function withStore(mode, callback) {
|
|||||||
|
|
||||||
export async function putStoredMessage(record) {
|
export async function putStoredMessage(record) {
|
||||||
if (!record || !record.messageKey) return;
|
if (!record || !record.messageKey) return;
|
||||||
|
const ownerLogin = normalizeOwnerLogin(record.ownerLogin);
|
||||||
|
const key = storageKey(ownerLogin, record.messageKey);
|
||||||
|
if (!key) return;
|
||||||
await withStore('readwrite', (store) => {
|
await withStore('readwrite', (store) => {
|
||||||
store.put(record);
|
store.put({ ...record, ownerLogin, storageKey: key });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStoredMessage(messageKey) {
|
export async function deleteStoredMessage(messageKey, ownerLogin = '') {
|
||||||
if (!messageKey) return;
|
const key = storageKey(ownerLogin, messageKey);
|
||||||
|
if (!key) return;
|
||||||
await withStore('readwrite', (store) => {
|
await withStore('readwrite', (store) => {
|
||||||
store.delete(messageKey);
|
store.delete(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listStoredMessages() {
|
export async function listStoredMessages(ownerLogin = '') {
|
||||||
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
|
if (!owner) return [];
|
||||||
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
||||||
const req = store.getAll();
|
const req = store.index('by_owner').getAll(owner);
|
||||||
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
||||||
req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
|
req.onerror = () => reject(req.error || new Error('IndexedDB getAll by owner failed'));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStoredMessages() {
|
export async function clearStoredMessages(ownerLogin = '') {
|
||||||
await new Promise((resolve, reject) => {
|
const owner = normalizeOwnerLogin(ownerLogin);
|
||||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
if (!owner) {
|
||||||
request.onsuccess = () => resolve();
|
await new Promise((resolve, reject) => {
|
||||||
request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
|
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||||
request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
|
request.onsuccess = () => resolve();
|
||||||
});
|
request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
|
||||||
|
request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await withStore('readwrite', (store) => new Promise((resolve, reject) => {
|
||||||
|
const index = store.index('by_owner');
|
||||||
|
const req = index.openKeyCursor(IDBKeyRange.only(owner));
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const cursor = req.result;
|
||||||
|
if (!cursor) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.delete(cursor.primaryKey);
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error || new Error('IndexedDB clear by owner failed'));
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function readArray(payload, key) {
|
|||||||
const aliases = {
|
const aliases = {
|
||||||
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
|
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
|
||||||
inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_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'],
|
outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
|
||||||
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
|
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
|
||||||
outShineSeen: ['outShineSeen', 'out_shine_seen'],
|
outShineSeen: ['outShineSeen', 'out_shine_seen'],
|
||||||
@@ -81,6 +83,8 @@ async function buildRelationsModel(login) {
|
|||||||
return {
|
return {
|
||||||
outFriends: [],
|
outFriends: [],
|
||||||
inFriends: [],
|
inFriends: [],
|
||||||
|
outCloseFriends: [],
|
||||||
|
inCloseFriends: [],
|
||||||
outContacts: [],
|
outContacts: [],
|
||||||
inContacts: [],
|
inContacts: [],
|
||||||
outFollows: [],
|
outFollows: [],
|
||||||
@@ -93,6 +97,8 @@ async function buildRelationsModel(login) {
|
|||||||
inSiblings: [],
|
inSiblings: [],
|
||||||
outKnownPersons: [],
|
outKnownPersons: [],
|
||||||
inKnownPersons: [],
|
inKnownPersons: [],
|
||||||
|
outOfficialAccounts: [],
|
||||||
|
inOfficialAccounts: [],
|
||||||
outShineConfirmed: [],
|
outShineConfirmed: [],
|
||||||
inShineConfirmed: [],
|
inShineConfirmed: [],
|
||||||
outShineSeen: [],
|
outShineSeen: [],
|
||||||
@@ -129,6 +135,8 @@ async function buildRelationsModel(login) {
|
|||||||
return {
|
return {
|
||||||
outFriends: readArray(graph, 'outFriends') || [],
|
outFriends: readArray(graph, 'outFriends') || [],
|
||||||
inFriends: readArray(graph, 'inFriends') || [],
|
inFriends: readArray(graph, 'inFriends') || [],
|
||||||
|
outCloseFriends: readArray(graph, 'outCloseFriends') || [],
|
||||||
|
inCloseFriends: readArray(graph, 'inCloseFriends') || [],
|
||||||
outContacts,
|
outContacts,
|
||||||
inContacts: readArray(graph, 'inContacts') || [],
|
inContacts: readArray(graph, 'inContacts') || [],
|
||||||
outFollows,
|
outFollows,
|
||||||
@@ -141,6 +149,8 @@ async function buildRelationsModel(login) {
|
|||||||
inSiblings: readArray(graph, 'inSiblings') || [],
|
inSiblings: readArray(graph, 'inSiblings') || [],
|
||||||
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
|
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
|
||||||
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
|
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
|
||||||
|
outOfficialAccounts: readArray(graph, 'outOfficialAccounts') || [],
|
||||||
|
inOfficialAccounts: readArray(graph, 'inOfficialAccounts') || [],
|
||||||
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
|
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
|
||||||
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
|
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
|
||||||
outShineSeen: readArray(graph, 'outShineSeen') || [],
|
outShineSeen: readArray(graph, 'outShineSeen') || [],
|
||||||
@@ -169,6 +179,8 @@ export async function loadCurrentRelations() {
|
|||||||
return {
|
return {
|
||||||
outFriends: [],
|
outFriends: [],
|
||||||
inFriends: [],
|
inFriends: [],
|
||||||
|
outCloseFriends: [],
|
||||||
|
inCloseFriends: [],
|
||||||
outContacts: [],
|
outContacts: [],
|
||||||
inContacts: [],
|
inContacts: [],
|
||||||
outFollows: [],
|
outFollows: [],
|
||||||
@@ -181,6 +193,8 @@ export async function loadCurrentRelations() {
|
|||||||
inSiblings: [],
|
inSiblings: [],
|
||||||
outKnownPersons: [],
|
outKnownPersons: [],
|
||||||
inKnownPersons: [],
|
inKnownPersons: [],
|
||||||
|
outOfficialAccounts: [],
|
||||||
|
inOfficialAccounts: [],
|
||||||
outShineConfirmed: [],
|
outShineConfirmed: [],
|
||||||
inShineConfirmed: [],
|
inShineConfirmed: [],
|
||||||
outShineSeen: [],
|
outShineSeen: [],
|
||||||
@@ -194,6 +208,8 @@ export function relationFlagsForTarget(relations, targetLogin) {
|
|||||||
return {
|
return {
|
||||||
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
|
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
|
||||||
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
|
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
|
||||||
|
outCloseFriend: listContainsLogin(relations?.outCloseFriends, targetLogin),
|
||||||
|
inCloseFriend: listContainsLogin(relations?.inCloseFriends, targetLogin),
|
||||||
outContact: listContainsLogin(relations?.outContacts, targetLogin),
|
outContact: listContainsLogin(relations?.outContacts, targetLogin),
|
||||||
inContact: listContainsLogin(relations?.inContacts, targetLogin),
|
inContact: listContainsLogin(relations?.inContacts, targetLogin),
|
||||||
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
|
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
|
||||||
@@ -206,6 +222,8 @@ export function relationFlagsForTarget(relations, targetLogin) {
|
|||||||
inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
|
inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
|
||||||
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
|
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
|
||||||
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
|
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
|
||||||
|
outOfficialAccount: listContainsLogin(relations?.outOfficialAccounts, targetLogin),
|
||||||
|
inOfficialAccount: listContainsLogin(relations?.inOfficialAccounts, targetLogin),
|
||||||
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
|
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
|
||||||
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
|
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
|
||||||
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
|
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
|
||||||
@@ -236,9 +254,24 @@ export async function loadUserProfileCard(login) {
|
|||||||
address: fields.address || '',
|
address: fields.address || '',
|
||||||
web: fields.web || '',
|
web: fields.web || '',
|
||||||
phone: fields.phone || '',
|
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',
|
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
||||||
official: Boolean(toggles.official),
|
// Compatibility aliases for older graph/card consumers; values come only from the new profile schema.
|
||||||
shine: Boolean(toggles.shine),
|
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
|
avatar: snapshot?.avatar?.txId
|
||||||
? {
|
? {
|
||||||
ar: String(snapshot.avatar.txId).trim(),
|
ar: String(snapshot.avatar.txId).trim(),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -10,14 +10,19 @@ export const profileFieldDefs = [
|
|||||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||||
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
|
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
|
||||||
{ key: 'address', readKeys: ['address'], 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: '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 = [
|
export const profileToggleDefs = []; // legacy boolean toggles are no longer used by the new UI.
|
||||||
{ key: 'official', label: 'Официальный' },
|
|
||||||
{ key: 'shine', label: 'Сияющий' },
|
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_MALE = 'male';
|
||||||
export const PROFILE_GENDER_FEMALE = 'female';
|
export const PROFILE_GENDER_FEMALE = 'female';
|
||||||
@@ -104,23 +109,31 @@ export async function loadProfileSnapshot(login) {
|
|||||||
key: field.key,
|
key: field.key,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
placeholder: field.placeholder,
|
placeholder: field.placeholder,
|
||||||
|
maxLength: Number(field.maxLength || 300),
|
||||||
|
multiline: Boolean(field.multiline),
|
||||||
value: latest?.value || '',
|
value: latest?.value || '',
|
||||||
timeMs: latest?.timeMs || 0,
|
timeMs: latest?.timeMs || 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggles = [];
|
const latestAccountRole = loadLatestByAliasesFromItems(items, ['account_role']);
|
||||||
for (let i = 0; i < profileToggleDefs.length; i += 1) {
|
const rawAccountRole = String(latestAccountRole?.value || '').trim().toLowerCase();
|
||||||
const toggle = profileToggleDefs[i];
|
const accountRole = rawAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY || rawAccountRole === PROFILE_ACCOUNT_ROLE_NON_VOTING
|
||||||
const latest = loadLatestByAliasesFromItems(items, [toggle.key]);
|
? rawAccountRole
|
||||||
toggles.push({
|
: '';
|
||||||
key: toggle.key,
|
|
||||||
label: toggle.label,
|
const latestShine = loadLatestByAliasesFromItems(items, ['shine']);
|
||||||
enabled: latest ? parseToggleValue(latest.value) : false,
|
const rawShine = String(latestShine?.value || '').trim().toLowerCase();
|
||||||
rawValue: latest?.value || 'no',
|
let shineStatus = '';
|
||||||
timeMs: latest?.timeMs || 0,
|
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 latestGender = loadLatestByAliasesFromItems(items, ['gender']);
|
||||||
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
|
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
|
||||||
@@ -145,6 +158,10 @@ export async function loadProfileSnapshot(login) {
|
|||||||
return {
|
return {
|
||||||
fields,
|
fields,
|
||||||
toggles,
|
toggles,
|
||||||
|
accountRole,
|
||||||
|
accountRoleTimeMs: latestAccountRole?.timeMs || 0,
|
||||||
|
shineStatus,
|
||||||
|
shineTimeMs: latestShine?.timeMs || 0,
|
||||||
gender,
|
gender,
|
||||||
genderTimeMs: latestGender?.timeMs || 0,
|
genderTimeMs: latestGender?.timeMs || 0,
|
||||||
avatar,
|
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();
|
const storagePwd = await getStoragePwd();
|
||||||
await authService.addBlockUserParam({
|
await authService.addBlockUserParam({ login, param: cleanKey, value: cleanValue, storagePwd });
|
||||||
login,
|
}
|
||||||
param: key,
|
|
||||||
value: enabled ? 'yes' : 'no',
|
// Kept only for old callers outside the current UI. New code must use saveProfileStatus().
|
||||||
storagePwd,
|
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) {
|
export async function saveProfileGender(login, gender) {
|
||||||
|
|||||||
+350
-3
@@ -11,6 +11,8 @@ import { emptyPasswordWords } from './services/password-words.js';
|
|||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
|
const PROFILES_STORAGE_KEY = 'shine-ui-profiles-v1';
|
||||||
|
const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
|
||||||
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
||||||
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
|
||||||
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
|
||||||
@@ -122,7 +124,121 @@ function normalizeToolsSettings(rawTools) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeProfileLogin(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProfileStoreRaw() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROFILES_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((item) => item && normalizeProfileLogin(item.login) && String(item.sessionId || '').trim());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistProfileStoreRaw(items) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(Array.isArray(items) ? items : []));
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveProfileLoginRaw() {
|
||||||
|
try {
|
||||||
|
return normalizeProfileLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveProfileLoginRaw(login) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
try {
|
||||||
|
if (normalized) localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, normalized);
|
||||||
|
else localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileEntrySettingsSnapshot(settings = {}) {
|
||||||
|
return {
|
||||||
|
solanaServer: String(settings.solanaServer || ''),
|
||||||
|
shineServer: String(settings.shineServer || ''),
|
||||||
|
shineServerLogin: String(settings.shineServerLogin || ''),
|
||||||
|
shineServerHttp: String(settings.shineServerHttp || ''),
|
||||||
|
arweaveServer: String(settings.arweaveServer || ''),
|
||||||
|
callPreflightTimeoutMs: Number(settings.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS),
|
||||||
|
remoteAddBlockSessionId: String(settings.remoteAddBlockSessionId || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertSavedProfileInternal({ login, sessionId, isLocalDemo = false, entrySettings = null } = {}) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
const cleanSessionId = String(sessionId || '').trim();
|
||||||
|
if (!normalized || !cleanSessionId) return;
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
|
||||||
|
const previous = index >= 0 ? items[index] : {};
|
||||||
|
const next = {
|
||||||
|
...previous,
|
||||||
|
login: String(login || '').trim(),
|
||||||
|
sessionId: cleanSessionId,
|
||||||
|
isLocalDemo: Boolean(isLocalDemo),
|
||||||
|
entrySettings: entrySettings ? profileEntrySettingsSnapshot(entrySettings) : (previous.entrySettings || {}),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
if (index >= 0) items[index] = next;
|
||||||
|
else items.push(next);
|
||||||
|
persistProfileStoreRaw(items);
|
||||||
|
setActiveProfileLoginRaw(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacySessionToProfileStore() {
|
||||||
|
const existing = loadProfileStoreRaw();
|
||||||
|
if (existing.length) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const legacy = JSON.parse(raw);
|
||||||
|
if (!legacy?.login || !legacy?.sessionId) return;
|
||||||
|
let entrySettings = {};
|
||||||
|
try {
|
||||||
|
entrySettings = JSON.parse(localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY) || '{}') || {};
|
||||||
|
} catch {}
|
||||||
|
upsertSavedProfileInternal({
|
||||||
|
login: legacy.login,
|
||||||
|
sessionId: legacy.sessionId,
|
||||||
|
isLocalDemo: legacy.isLocalDemo,
|
||||||
|
entrySettings,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore migration errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadStoredSession() {
|
function loadStoredSession() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
if (profiles.length) {
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
|
||||||
|
if (active) {
|
||||||
|
setActiveProfileLoginRaw(active.login);
|
||||||
|
return {
|
||||||
|
isAuthorized: false,
|
||||||
|
isLocalDemo: Boolean(active.isLocalDemo),
|
||||||
|
login: String(active.login || '').trim(),
|
||||||
|
sessionId: String(active.sessionId || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -169,6 +285,15 @@ function clearStoredSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredEntrySettings() {
|
function loadStoredEntrySettings() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
if (profiles.length) {
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
|
||||||
|
if (active?.entrySettings && typeof active.entrySettings === 'object') {
|
||||||
|
return active.entrySettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
|
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -199,6 +324,19 @@ function persistEntrySettings(settings) {
|
|||||||
tools: normalizeToolsSettings(settings?.tools),
|
tools: normalizeToolsSettings(settings?.tools),
|
||||||
};
|
};
|
||||||
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
if (activeLogin) {
|
||||||
|
const profiles = loadProfileStoreRaw();
|
||||||
|
const index = profiles.findIndex((item) => normalizeProfileLogin(item.login) === activeLogin);
|
||||||
|
if (index >= 0) {
|
||||||
|
profiles[index] = {
|
||||||
|
...profiles[index],
|
||||||
|
entrySettings: profileEntrySettingsSnapshot(payload),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
persistProfileStoreRaw(profiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
@@ -244,6 +382,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
pendingIncomingReadByBaseKey: {},
|
pendingIncomingReadByBaseKey: {},
|
||||||
outgoingTempSeq: 1,
|
outgoingTempSeq: 1,
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
|
notificationUnreadTotal: 0,
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
isAuthorized: storedLocalDemo,
|
isAuthorized: storedLocalDemo,
|
||||||
@@ -401,6 +540,7 @@ function persistMessageRecord(chatId, row) {
|
|||||||
const resolvedTs = resolveChatMessageTimeMs(row);
|
const resolvedTs = resolveChatMessageTimeMs(row);
|
||||||
void putStoredMessage({
|
void putStoredMessage({
|
||||||
messageKey: row.messageKey,
|
messageKey: row.messageKey,
|
||||||
|
ownerLogin: String(state.session.login || '').trim().toLowerCase(),
|
||||||
chatId: normalizedChatId,
|
chatId: normalizedChatId,
|
||||||
from: row.from || 'in',
|
from: row.from || 'in',
|
||||||
text: String(row.text || ''),
|
text: String(row.text || ''),
|
||||||
@@ -421,12 +561,12 @@ function persistMessageRecord(chatId, row) {
|
|||||||
|
|
||||||
function removeStoredMessageRecord(messageKey) {
|
function removeStoredMessageRecord(messageKey) {
|
||||||
if (!messageKey) return;
|
if (!messageKey) return;
|
||||||
void deleteStoredMessage(messageKey).catch(() => {});
|
void deleteStoredMessage(messageKey, state.session.login).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hydrateMessagesFromStore() {
|
export async function hydrateMessagesFromStore() {
|
||||||
try {
|
try {
|
||||||
const rows = await listStoredMessages();
|
const rows = await listStoredMessages(state.session.login);
|
||||||
const touchedChats = new Set();
|
const touchedChats = new Set();
|
||||||
rows
|
rows
|
||||||
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
||||||
@@ -927,6 +1067,7 @@ export function authorizeSession({
|
|||||||
login,
|
login,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings });
|
||||||
authService.setActiveSessionContext({ login, sessionId });
|
authService.setActiveSessionContext({ login, sessionId });
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
if (onSessionAuthorized) {
|
if (onSessionAuthorized) {
|
||||||
@@ -1019,13 +1160,14 @@ async function tryCloseCurrentSessionOnServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
||||||
|
const signedOutLogin = String(state.session.login || '').trim();
|
||||||
if (closeServerSession) {
|
if (closeServerSession) {
|
||||||
await tryCloseCurrentSessionOnServer();
|
await tryCloseCurrentSessionOnServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages(signedOutLogin).catch(() => {});
|
||||||
authService.close();
|
authService.close();
|
||||||
authService.clearActiveSessionContext();
|
authService.clearActiveSessionContext();
|
||||||
if (infoMessage) {
|
if (infoMessage) {
|
||||||
@@ -1045,6 +1187,211 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
|||||||
await terminateCurrentSession({ infoMessage, closeServerSession: true });
|
await terminateCurrentSession({ infoMessage, closeServerSession: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getSavedProfiles() {
|
||||||
|
migrateLegacySessionToProfileStore();
|
||||||
|
const activeLogin = getActiveProfileLoginRaw();
|
||||||
|
return loadProfileStoreRaw().map((item) => ({
|
||||||
|
login: String(item.login || '').trim(),
|
||||||
|
sessionId: String(item.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(item.isLocalDemo),
|
||||||
|
isActive: normalizeProfileLogin(item.login) === activeLogin,
|
||||||
|
entrySettings: item.entrySettings || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchToSavedProfile(login) {
|
||||||
|
const targetLogin = normalizeProfileLogin(login);
|
||||||
|
const target = loadProfileStoreRaw().find((item) => normalizeProfileLogin(item.login) === targetLogin);
|
||||||
|
if (!target) throw new Error('Профиль не найден на этом устройстве');
|
||||||
|
if (targetLogin === normalizeProfileLogin(state.session.login)) return target;
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
sessionId: String(state.session.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(state.session.isLocalDemo),
|
||||||
|
server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(),
|
||||||
|
};
|
||||||
|
const targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
|
||||||
|
// В каждый момент времени держим только один WebSocket: сначала полностью
|
||||||
|
// закрываем transport активного профиля, затем создаём новый для target.
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
try {
|
||||||
|
const resumed = await authService.resumeSession(target.login, target.sessionId);
|
||||||
|
target.login = resumed.login || target.login;
|
||||||
|
target.sessionId = resumed.sessionId || target.sessionId;
|
||||||
|
target.updatedAtMs = Date.now();
|
||||||
|
authService.setActiveSessionContext({ login: target.login, sessionId: target.sessionId });
|
||||||
|
persistProfileStoreRaw(loadProfileStoreRaw().map((item) => (
|
||||||
|
normalizeProfileLogin(item.login) === targetLogin ? target : item
|
||||||
|
)));
|
||||||
|
setActiveProfileLoginRaw(target.login);
|
||||||
|
persistSession({ isAuthorized: true, isLocalDemo: Boolean(target.isLocalDemo), login: target.login, sessionId: target.sessionId });
|
||||||
|
if (target.entrySettings && typeof target.entrySettings === 'object') {
|
||||||
|
persistEntrySettings({ ...state.entrySettings, ...target.entrySettings });
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
} catch (switchError) {
|
||||||
|
// Если новый профиль не поднялся — создаём новый socket обратно для старого.
|
||||||
|
try {
|
||||||
|
authService.resetConnection(origin.server, { clearSessionContext: true });
|
||||||
|
if (origin.login && origin.sessionId && !origin.isLocalDemo) {
|
||||||
|
const restored = await authService.resumeSession(origin.login, origin.sessionId);
|
||||||
|
authService.setActiveSessionContext({
|
||||||
|
login: restored?.login || origin.login,
|
||||||
|
sessionId: restored?.sessionId || origin.sessionId,
|
||||||
|
});
|
||||||
|
} else if (origin.login) {
|
||||||
|
authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId });
|
||||||
|
}
|
||||||
|
} catch (restoreError) {
|
||||||
|
console.warn('[profiles] failed to restore previous profile connection after switch failure', restoreError);
|
||||||
|
}
|
||||||
|
throw switchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSavedProfileSessionBestEffort(profile) {
|
||||||
|
if (!profile || profile.isLocalDemo) return;
|
||||||
|
const cleanSessionId = String(profile.sessionId || '').trim();
|
||||||
|
if (!cleanSessionId) return;
|
||||||
|
const normalized = normalizeProfileLogin(profile.login);
|
||||||
|
const activeNormalized = normalizeProfileLogin(state.session.login);
|
||||||
|
if (normalized === activeNormalized && state.session.isAuthorized) {
|
||||||
|
try { await authService.closeSession(cleanSessionId); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
sessionId: String(state.session.sessionId || '').trim(),
|
||||||
|
isLocalDemo: Boolean(state.session.isLocalDemo),
|
||||||
|
server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(),
|
||||||
|
};
|
||||||
|
const targetServer = String(profile?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
try {
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(profile.login, cleanSessionId);
|
||||||
|
await authService.closeSession(cleanSessionId);
|
||||||
|
} catch {
|
||||||
|
// Закрытие профиля на устройстве не блокируем из-за недоступного сервера.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
authService.resetConnection(origin.server, { clearSessionContext: true });
|
||||||
|
if (origin.login && origin.sessionId && !origin.isLocalDemo) {
|
||||||
|
const restored = await authService.resumeSession(origin.login, origin.sessionId);
|
||||||
|
authService.setActiveSessionContext({
|
||||||
|
login: restored?.login || origin.login,
|
||||||
|
sessionId: restored?.sessionId || origin.sessionId,
|
||||||
|
});
|
||||||
|
} else if (origin.login) {
|
||||||
|
authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[profiles] failed to restore active profile after closing another profile', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSavedProfile(login) {
|
||||||
|
const normalized = normalizeProfileLogin(login);
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
|
||||||
|
if (index < 0) return { closed: false, nextProfile: null };
|
||||||
|
const target = items[index];
|
||||||
|
await closeSavedProfileSessionBestEffort(target);
|
||||||
|
await clearStoredMessages(target.login).catch(() => {});
|
||||||
|
|
||||||
|
const nextItems = items.filter((_, itemIndex) => itemIndex !== index);
|
||||||
|
persistProfileStoreRaw(nextItems);
|
||||||
|
const wasActive = normalized === getActiveProfileLoginRaw();
|
||||||
|
if (!wasActive) return { closed: true, nextProfile: null };
|
||||||
|
|
||||||
|
const next = nextItems[index] || nextItems[index - 1] || nextItems[0] || null;
|
||||||
|
if (!next) {
|
||||||
|
setActiveProfileLoginRaw('');
|
||||||
|
clearStoredSession();
|
||||||
|
return { closed: true, nextProfile: null };
|
||||||
|
}
|
||||||
|
setActiveProfileLoginRaw(next.login);
|
||||||
|
persistSession({ isAuthorized: true, isLocalDemo: Boolean(next.isLocalDemo), login: next.login, sessionId: next.sessionId });
|
||||||
|
if (next.entrySettings && typeof next.entrySettings === 'object') {
|
||||||
|
persistEntrySettings({ ...state.entrySettings, ...next.entrySettings });
|
||||||
|
}
|
||||||
|
return { closed: true, nextProfile: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeAllSavedProfiles() {
|
||||||
|
const items = loadProfileStoreRaw();
|
||||||
|
for (const item of items) {
|
||||||
|
await clearStoredMessages(item.login).catch(() => {});
|
||||||
|
if (item.isLocalDemo || !String(item.sessionId || '').trim()) continue;
|
||||||
|
const targetServer = String(item?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
|
||||||
|
try {
|
||||||
|
authService.resetConnection(targetServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(item.login, item.sessionId);
|
||||||
|
await authService.closeSession(item.sessionId);
|
||||||
|
} catch {
|
||||||
|
// Все локальные профили всё равно закрываем, даже если один сервер недоступен.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistProfileStoreRaw([]);
|
||||||
|
setActiveProfileLoginRaw('');
|
||||||
|
clearStoredSession();
|
||||||
|
authService.close();
|
||||||
|
authService.clearActiveSessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAddingProfileLogin() {
|
||||||
|
return state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareAddProfileLogin() {
|
||||||
|
state.loginDraft.login = '';
|
||||||
|
state.loginDraft.password = '';
|
||||||
|
clearAuthMessages();
|
||||||
|
// While an existing profile stays authorized, PRE_AUTH login pages are normally
|
||||||
|
// blocked by app.js. This return target also acts as an explicit add-profile mode.
|
||||||
|
state.authReturnHash = '/profiles';
|
||||||
|
|
||||||
|
// Не пытаемся авторизовать второй login через уже authenticated socket.
|
||||||
|
// Старую серверную сессию НЕ закрываем: закрываем только локальный transport.
|
||||||
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAddProfileLogin() {
|
||||||
|
const wasAddingProfile = isAddingProfileLogin();
|
||||||
|
const shouldRestoreActiveConnection = wasAddingProfile
|
||||||
|
&& state.session.isAuthorized
|
||||||
|
&& Boolean(String(state.session.login || '').trim())
|
||||||
|
&& Boolean(String(state.session.sessionId || '').trim());
|
||||||
|
|
||||||
|
state.authReturnHash = '';
|
||||||
|
state.loginDraft.login = '';
|
||||||
|
state.loginDraft.password = '';
|
||||||
|
resetRegistrationFlow();
|
||||||
|
clearAuthMessages();
|
||||||
|
|
||||||
|
if (shouldRestoreActiveConnection) {
|
||||||
|
try {
|
||||||
|
authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true });
|
||||||
|
await authService.resumeSession(state.session.login, state.session.sessionId);
|
||||||
|
authService.setActiveSessionContext({ login: state.session.login, sessionId: state.session.sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[profiles] failed to restore active profile connection after cancelling add-profile flow', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wasAddingProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeAuthReturnPage(fallback = 'profile-view') {
|
||||||
|
const nextHash = String(state.authReturnHash || '').trim();
|
||||||
|
state.authReturnHash = '';
|
||||||
|
if (nextHash.startsWith('/')) return nextHash.slice(1) || fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshRegistrationBalance() {
|
export function refreshRegistrationBalance() {
|
||||||
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
const next = (0.005 + Math.random() * 0.03).toFixed(4);
|
||||||
state.registrationPayment.balanceSOL = next;
|
state.registrationPayment.balanceSOL = next;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
.badge-line { display: flex; gap: 8px; flex-wrap: wrap; }
|
.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 { 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.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; }
|
.badge.err { color: #f08080; border-color: #5a2a2a; background: #2e1a1a; }
|
||||||
.details-wrap { margin-top: 12px; }
|
.details-wrap { margin-top: 12px; }
|
||||||
.details-wrap details { border: 1px solid var(--border); border-radius: var(--radius); background: #121212; }
|
.details-wrap details { border: 1px solid var(--border); border-radius: var(--radius); background: #121212; }
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
.expected-row:last-child { margin-bottom:0; }
|
.expected-row:last-child { margin-bottom:0; }
|
||||||
.expected-lbl { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
|
.expected-lbl { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
|
||||||
.expected-val { font-family:monospace; font-size:11px; word-break:break-all; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1379
-142
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,8 @@ body::before {
|
|||||||
--toolbar-height: 78px;
|
--toolbar-height: 78px;
|
||||||
--keyboard-offset: 0px;
|
--keyboard-offset: 0px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
border-left: 1px solid transparent;
|
||||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
border-right: 1px solid transparent;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connection-retry-banner.is-connecting {
|
.connection-retry-banner.is-connecting {
|
||||||
border-color: rgba(238, 196, 107, 0.42);
|
border-color: transparent;
|
||||||
color: #ffe8bb;
|
color: #ffe8bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,14 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-card--new { background: rgba(108, 92, 231, .10); border-color: rgba(143, 126, 255, .42); }
|
||||||
|
.notification-card--new::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; position: absolute; right: 12px; top: 12px; opacity: .9; }
|
||||||
|
.notification-card { position: relative; }
|
||||||
|
.notification-new-divider { text-align: center; font-size: 11px; letter-spacing: .12em; opacity: .72; padding: 6px 0; }
|
||||||
|
|
||||||
|
.channel-view-topbar { position: relative; }
|
||||||
|
.channel-header-more-menu { position: absolute; right: 10px; top: calc(100% - 4px); z-index: 80; min-width: 190px; padding: 7px; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: rgba(18,18,28,.96); backdrop-filter: blur(18px); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
|
||||||
|
.channel-header-more-menu button { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 10px 12px; border-radius: 10px; }
|
||||||
|
.channel-header-more-menu button:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.channel-header-more-menu button.is-danger { color: #ff8c9b; }
|
||||||
|
|||||||
@@ -242,10 +242,9 @@
|
|||||||
.fg-node.is-pressed .node-dot { transform: none; }
|
.fg-node.is-pressed .node-dot { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
||||||
Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр).
|
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
|
||||||
Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 ↔ 1.0 —
|
визуально сияющий пользователь всегда остаётся явно сияющим. */
|
||||||
как мягкое свечение живого организма в темноте, а не «жирный маркер». */
|
|
||||||
.fg-node.is-shine .node-dot {
|
.fg-node.is-shine .node-dot {
|
||||||
border-color: rgba(150, 240, 255, 0.62);
|
border-color: rgba(150, 240, 255, 0.62);
|
||||||
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
||||||
@@ -268,9 +267,9 @@
|
|||||||
@keyframes fg-shine-glow {
|
@keyframes fg-shine-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 5px rgba(125, 232, 255, 0.30),
|
0 0 7px rgba(138, 239, 255, 0.48),
|
||||||
0 0 11px rgba(112, 226, 255, 0.18),
|
0 0 15px rgba(118, 232, 255, 0.32),
|
||||||
0 0 20px rgba(100, 220, 255, 0.10);
|
0 0 27px rgba(100, 220, 255, 0.19);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -282,7 +281,7 @@
|
|||||||
|
|
||||||
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
||||||
@keyframes fg-shine-halo {
|
@keyframes fg-shine-halo {
|
||||||
0%, 100% { transform: scale(0.9); opacity: 0.5; }
|
0%, 100% { transform: scale(0.98); opacity: 0.72; }
|
||||||
50% { transform: scale(1.12); opacity: 1; }
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,79 +530,6 @@
|
|||||||
background: transparent;
|
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: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
|
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
|
||||||
|
|
||||||
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
|
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
|
||||||
@@ -722,6 +648,6 @@
|
|||||||
|
|
||||||
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */
|
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */
|
||||||
.fg-node.is-common .node-dot {
|
.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);
|
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user