SHA256
Compare commits
47
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
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 | ||
|
|
390a2b988e |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
Binary file not shown.
@@ -102,16 +102,23 @@ public final class MsgSubType {
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в друзья. */
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
/** Удалить связь "жена/муж". */
|
||||
@@ -132,9 +139,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -142,11 +148,17 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
/** Снимаю подтверждение официального аккаунта этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
|
||||
+9
-3
@@ -20,9 +20,11 @@ import java.util.Objects;
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
||||
* FRIEND=14, UNFRIEND=15
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* [4] lineCode
|
||||
@@ -185,6 +187,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
@@ -202,7 +206,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+3
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_14 = 14;
|
||||
public static final int SCHEMA_VERSION_15 = 15;
|
||||
public static final int SCHEMA_VERSION_16 = 16;
|
||||
public static final int SCHEMA_VERSION_17 = 17;
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
public static final int SCHEMA_VERSION_19 = 19;
|
||||
public static final int SCHEMA_VERSION_20 = 20;
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -49,6 +54,11 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
||||
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -70,12 +80,17 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется; user-to-user FOLLOW legacy. DO NOT REMOVE.
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. DO NOT REMOVE.
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
@@ -88,15 +103,20 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
// Legacy 60/61: новый UI не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. DO NOT REMOVE.
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
|
||||
String user,
|
||||
String password) throws SQLException {
|
||||
@@ -172,6 +192,26 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_16;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_17) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_17;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_18) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_18;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_19) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_19;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_20) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_21;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,10 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
@@ -70,18 +70,26 @@ public final class MsgSubType {
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в друзья. */
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
|
||||
@@ -106,10 +114,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -118,12 +124,19 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждение официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
|
||||
/** Снятие подтверждения официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
|
||||
+19
@@ -426,6 +426,25 @@ public final class BlockchainResyncCleanupDAO {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Server projection for per-user channel read/unread state.
|
||||
*
|
||||
* Important rules:
|
||||
* - rows are created only by a signed client SetChannelReadState;
|
||||
* - channel publications increment unread_count only for rows that already exist;
|
||||
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
|
||||
* - unread_count is never part of the client signature and is server-owned.
|
||||
*/
|
||||
public final class ChannelReadStateDAO {
|
||||
private static volatile ChannelReadStateDAO instance;
|
||||
|
||||
private ChannelReadStateDAO() {}
|
||||
|
||||
public static ChannelReadStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelReadStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelReadStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
|
||||
String clientKey, String readSignature) {}
|
||||
|
||||
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
|
||||
|
||||
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
|
||||
FROM channel_read_state
|
||||
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new ReadState(
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")),
|
||||
rs.getLong("read_updated_at_ms"),
|
||||
rs.getString("client_key"),
|
||||
rs.getString("read_signature"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return state == null ? 0L : state.unreadCount();
|
||||
}
|
||||
|
||||
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(SUM(crs.unread_count),0)
|
||||
FROM channel_read_state crs
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=30
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the requested public channel exists and that viewer currently follows it.
|
||||
*/
|
||||
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
|
||||
int followRelType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(?)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=?
|
||||
AND cns.slug=?
|
||||
AND cns.channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, followRelType);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT slug
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("slug") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT channel_root_block_number
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("owner_login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
|
||||
* Older timestamps and backwards read_count values are ignored.
|
||||
*/
|
||||
public UpsertResult upsertSignedReadIfNewer(Connection c,
|
||||
String viewerLogin,
|
||||
String ownerLogin,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
long readCount,
|
||||
long readUpdatedAtMs,
|
||||
String clientKey,
|
||||
String readSignature,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_read_state (
|
||||
viewer_login, owner_login, owner_bch_name, channel_name,
|
||||
read_count, unread_count,
|
||||
read_updated_at_ms, client_key, read_signature, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
|
||||
DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
unread_count = GREATEST(
|
||||
0,
|
||||
channel_read_state.unread_count -
|
||||
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
|
||||
),
|
||||
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
|
||||
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
|
||||
client_key = EXCLUDED.client_key,
|
||||
read_signature = EXCLUDED.read_signature,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
|
||||
AND EXCLUDED.read_count >= channel_read_state.read_count
|
||||
RETURNING read_count, unread_count
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
ps.setLong(5, Math.max(0L, readCount));
|
||||
ps.setLong(6, readUpdatedAtMs);
|
||||
ps.setString(7, clientKey);
|
||||
ps.setString(8, readSignature);
|
||||
ps.setLong(9, nowMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new UpsertResult(true,
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")));
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return new UpsertResult(false,
|
||||
current == null ? 0L : current.readCount(),
|
||||
current == null ? 0L : current.unreadCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments existing rows for current subscribers of one public channel.
|
||||
* Missing channel_read_state rows are deliberately NOT created.
|
||||
* Returns affected viewer logins for post-commit counter pushes.
|
||||
*/
|
||||
public List<String> incrementUnreadForSubscribers(Connection c,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
int followRelType,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_read_state crs
|
||||
SET unread_count = crs.unread_count + 1,
|
||||
updated_at_ms = ?
|
||||
WHERE crs.owner_bch_name = ?
|
||||
AND crs.channel_name = ?
|
||||
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
RETURNING viewer_login
|
||||
""";
|
||||
List<String> affected = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
ps.setInt(4, followRelType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) affected.add(rs.getString("viewer_login"));
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
|
||||
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
|
||||
int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM channel_read_state crs
|
||||
USING channel_names_state cns
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND crs.owner_bch_name=?
|
||||
AND cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_root_block_number=?
|
||||
AND cns.channel_type_code=1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setInt(3, rootBlockNumber);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public final class DmDialogStateDAO {
|
||||
|
||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
@@ -76,16 +77,18 @@ public final class DmDialogStateDAO {
|
||||
ps.setString(1, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||
int unreadCount = rs.getInt("unread_count");
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
rs.getLong("last_message_time_ms"),
|
||||
rs.getInt("unread_count"),
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
true
|
||||
lastMessageTimeMs > 0 || unreadCount > 0
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
@@ -95,6 +98,9 @@ public final class DmDialogStateDAO {
|
||||
for (String peer : contacts) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||
}
|
||||
for (String peer : friends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
|
||||
}
|
||||
for (String peer : closeFriends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||
}
|
||||
@@ -286,6 +292,9 @@ public final class DmDialogStateDAO {
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
@@ -357,6 +366,8 @@ public final class DmDialogStateDAO {
|
||||
String nextRelation = current.relationFlag();
|
||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "close_friend";
|
||||
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "friend";
|
||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "contact";
|
||||
} else {
|
||||
@@ -433,7 +444,7 @@ public final class DmDialogStateDAO {
|
||||
|
||||
private String normalizeRelationFlag(String value) {
|
||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
return "none";
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public final class UserNotificationSeenStateDAO {
|
||||
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||
private UserNotificationSeenStateDAO() {}
|
||||
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||
|
||||
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||
ps.setString(1, login); ps.setString(2, category);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||
String sql = """
|
||||
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return getSeenAt(c, login, category);
|
||||
}
|
||||
}
|
||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
ps.setLong(4, Math.max(0, cutoffMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
|
||||
WHERE users_params.time_ms < excluded.time_ms
|
||||
""";
|
||||
|
||||
int changed;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setString(2, e.getParam());
|
||||
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
|
||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||
else ps.setNull(6, Types.VARCHAR);
|
||||
|
||||
return ps.executeUpdate();
|
||||
changed = ps.executeUpdate();
|
||||
}
|
||||
if (changed > 0 && affectsFastProfile(e.getParam())) {
|
||||
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
||||
@@ -77,6 +82,32 @@ public final class UserParamsDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static boolean affectsFastProfile(String param) {
|
||||
if (param == null) return false;
|
||||
return switch (param) {
|
||||
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
if ("account_role".equals(param) || "shine".equals(param)) {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
|
||||
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||
public final class UserProfileStateDAO {
|
||||
private static volatile UserProfileStateDAO instance;
|
||||
private UserProfileStateDAO() {}
|
||||
public static UserProfileStateDAO getInstance() {
|
||||
if (instance == null) synchronized (UserProfileStateDAO.class) {
|
||||
if (instance == null) instance = new UserProfileStateDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ProfileCard get(Connection c, String login) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
|
||||
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
|
||||
return map(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||
*
|
||||
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
|
||||
* older/current blocks may address a user by blockchain name, so a direct to_login-only
|
||||
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
|
||||
*/
|
||||
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
|
||||
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||
int safeOffset = Math.max(0, offset);
|
||||
String where = switch (String.valueOf(listType)) {
|
||||
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
|
||||
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
|
||||
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
|
||||
};
|
||||
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
|
||||
String personExpr = received ? "cs.login" : "cs.to_login";
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (LOWER(%s))
|
||||
%s AS person_login,
|
||||
COALESCE(up.first_name,'') AS first_name,
|
||||
COALESCE(up.last_name,'') AS last_name,
|
||||
COALESCE(up.ava_ar,'') AS ava_ar,
|
||||
up.account_role, up.shine_status,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
|
||||
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
|
||||
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
|
||||
CASE
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
|
||||
ELSE 'none' END AS relation_type,
|
||||
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
|
||||
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
|
||||
FROM connections_state cs
|
||||
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
|
||||
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
|
||||
WHERE %s
|
||||
ORDER BY LOWER(%s), %s
|
||||
LIMIT ? OFFSET ?
|
||||
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
|
||||
List<RelationCard> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i=1;
|
||||
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
|
||||
ps.setString(i++, ownerLogin);
|
||||
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(new RelationCard(
|
||||
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
|
||||
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
|
||||
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
|
||||
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
|
||||
String sql;
|
||||
if ("owned".equals(mode)) {
|
||||
sql="""
|
||||
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||
ORDER BY 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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Channel unread/read projection.
|
||||
-- The row is created only by a signed SetChannelReadState from the client.
|
||||
-- New channel publications only increment existing rows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 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
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -425,6 +425,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -765,7 +785,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
@@ -1113,7 +1133,7 @@ BEGIN
|
||||
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
|
||||
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
|
||||
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
@@ -1288,6 +1308,7 @@ BEGIN
|
||||
|
||||
positive_rel_type := CASE NEW.msg_sub_type
|
||||
WHEN 11 THEN 10
|
||||
WHEN 15 THEN 14
|
||||
WHEN 21 THEN 20
|
||||
WHEN 31 THEN 30
|
||||
WHEN 41 THEN 40
|
||||
@@ -1297,6 +1318,7 @@ BEGIN
|
||||
WHEN 61 THEN 60
|
||||
WHEN 71 THEN 70
|
||||
WHEN 75 THEN 74
|
||||
WHEN 81 THEN 80
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
@@ -1644,4 +1666,371 @@ AFTER INSERT ON channel_names_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||
|
||||
|
||||
-- v18 integrated fresh-schema definitions
|
||||
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||
login TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
ava_ar TEXT NOT NULL DEFAULT '',
|
||||
account_role TEXT,
|
||||
shine_status TEXT,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||
|
||||
ALTER TABLE user_stats_state
|
||||
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||
|
||||
ALTER TABLE message_stats
|
||||
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_first_name TEXT := '';
|
||||
v_last_name TEXT := '';
|
||||
v_ava_ar TEXT := '';
|
||||
v_account_role TEXT := NULL;
|
||||
v_shine TEXT := NULL;
|
||||
v_updated BIGINT := 0;
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||
MAX(value) FILTER (WHERE param = 'shine'),
|
||||
COALESCE(MAX(time_ms), 0)
|
||||
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||
FROM users_params
|
||||
WHERE LOWER(login) = LOWER(p_login);
|
||||
|
||||
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||
|
||||
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||
|
||||
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
ava_ar = EXCLUDED.ava_ar,
|
||||
account_role = EXCLUDED.account_role,
|
||||
shine_status = EXCLUDED.shine_status,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = p_login
|
||||
LIMIT 1
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||
FROM reactions_state rs
|
||||
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
LOOP
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_login TEXT;
|
||||
v_channel_type INTEGER;
|
||||
v_count INTEGER := 0;
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT cn.owner_login, cn.channel_type_code
|
||||
INTO v_owner_login, v_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name=p_bch_name
|
||||
AND cn.channel_root_block_number=p_block_number
|
||||
AND cn.channel_root_block_hash=p_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||
|
||||
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||
INTO v_count
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type=30
|
||||
AND cs.to_bch_name=p_bch_name
|
||||
AND cs.to_block_number=p_block_number
|
||||
AND cs.to_block_hash=p_block_hash;
|
||||
|
||||
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
|
||||
owner_login=EXCLUDED.owner_login,
|
||||
channel_type_code=EXCLUDED.channel_type_code,
|
||||
subscribers_count=EXCLUDED.subscribers_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
|
||||
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
|
||||
ELSE
|
||||
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
END IF;
|
||||
|
||||
PERFORM shine_refresh_user_stats(NEW.login);
|
||||
PERFORM shine_refresh_user_stats(resolved_login);
|
||||
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE previous_sub_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||
|
||||
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||
|
||||
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||
UPDATE message_stats SET likes_count=likes_count+1,
|
||||
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
END IF;
|
||||
|
||||
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Initial projection/rebuild.
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
owner_login VARCHAR(60) NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
signed_blob BYTEA NOT NULL,
|
||||
signed_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,21,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;
|
||||
|
||||
|
||||
+24
@@ -75,26 +75,38 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_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_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
@@ -202,13 +214,19 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -290,13 +308,19 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
|
||||
+56
-11
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -563,6 +565,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setEditedByBlockNumber(be.getToBlockNumber());
|
||||
}
|
||||
|
||||
if (block.body instanceof ConnectionBody connectionBody) {
|
||||
String votingError = validateConnectionVotingRights(login, block.subType & 0xFFFF, connectionBody.toLogin());
|
||||
if (votingError != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, votingError, serverLastNum, serverLastHashHex);
|
||||
}
|
||||
}
|
||||
|
||||
UserParamEntry upsertedParam = null;
|
||||
if (block.body instanceof UserParamBody upBody) {
|
||||
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
|
||||
@@ -579,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
}
|
||||
@@ -609,6 +622,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
if (targetLogin == null || targetLogin.isBlank()) return "bad_connection_target";
|
||||
String sql = subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
? "SELECT shine_is_primary(?), shine_target_accepts_primary_confirmation(?)"
|
||||
: "SELECT (shine_is_primary(?) AND shine_is_shining(?)), shine_target_accepts_shine_confirmation(?)";
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
if (subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)) {
|
||||
ps.setString(1, actorLogin); ps.setString(2, targetLogin);
|
||||
} else {
|
||||
ps.setString(1, actorLogin); ps.setString(2, actorLogin); ps.setString(3, targetLogin);
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next() || !rs.getBoolean(1)) return subType == 80 ? "primary_vote_not_allowed" : "shine_vote_not_allowed";
|
||||
if (!rs.getBoolean(2)) return subType == 80 ? "target_non_voting" : "target_shine_not_interested";
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Connection voting permission check failed actor={} target={} subtype={}", actorLogin, targetLogin, subType, e);
|
||||
return "voting_permission_check_failed";
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeBase64(String b64) {
|
||||
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
||||
return Base64Ws.decode(b64);
|
||||
@@ -884,16 +922,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||
if (personalConnection || channelEvent) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
@@ -19,6 +21,8 @@ import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
@@ -44,6 +48,7 @@ public final class BlockchainWriter {
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
@@ -58,7 +63,7 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -75,6 +80,7 @@ public final class BlockchainWriter {
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
@@ -108,6 +114,29 @@ public final class BlockchainWriter {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
@@ -142,6 +171,17 @@ public final class BlockchainWriter {
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
|
||||
+11
@@ -138,6 +138,17 @@ public final class ChannelNamesStateBootstrapper {
|
||||
dao.insertAll(c, entries);
|
||||
|
||||
applyMetaUpdates(c, dao, entries, skipped);
|
||||
// v18: counters are a projection, so rebuild them from current state after a full channel-name bootstrap.
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT shine_refresh_channel_follow_stats(owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
""")) {
|
||||
ps.execute();
|
||||
}
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
|
||||
+14
-35
@@ -144,38 +144,12 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
||||
String name = channelName == null ? "" : channelName.trim();
|
||||
return bch + "/" + name;
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
boolean bounded = limit > 0;
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
""" + order + (bounded ? " LIMIT ?" : "");
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||
ps.setInt(9, lineCode);
|
||||
ps.setInt(10, limit);
|
||||
if (bounded) ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
@@ -342,8 +317,10 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
|
||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
int likesCount = 0;
|
||||
int primaryLikesCount = 0;
|
||||
int shiningLikesCount = 0;
|
||||
int repliesCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
@@ -352,6 +329,8 @@ final class ChannelsReadSupport {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
likesCount = rs.getInt("likes_count");
|
||||
primaryLikesCount = rs.getInt("primary_likes_count");
|
||||
shiningLikesCount = rs.getInt("shining_likes_count");
|
||||
repliesCount = rs.getInt("replies_count");
|
||||
}
|
||||
}
|
||||
@@ -378,7 +357,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 {
|
||||
|
||||
+8
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
// limit is optional. null/0 means "return the whole channel".
|
||||
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||
if (limit < 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||
}
|
||||
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
if (!asc) {
|
||||
java.util.Collections.reverse(posts);
|
||||
}
|
||||
if (posts.size() > limit) {
|
||||
if (limit > 0 && posts.size() > limit) {
|
||||
posts = new ArrayList<>(posts.subList(0, limit));
|
||||
}
|
||||
}
|
||||
@@ -169,6 +171,8 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setPrimaryLikesCount(stats[3]);
|
||||
item.setShiningLikesCount(stats[4]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setRatingsCount(stats[2]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
final byte[] blockHash;
|
||||
try {
|
||||
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
if (blockHash == null || blockHash.length == 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String sql = """
|
||||
SELECT rs.from_login,
|
||||
COALESCE(ups.first_name, '') AS first_name,
|
||||
COALESCE(ups.last_name, '') AS last_name,
|
||||
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||
FROM reactions_state rs
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||
WHERE rs.reaction_type = 1
|
||||
AND rs.last_sub_type = 1
|
||||
AND rs.to_bch_name = ?
|
||||
AND rs.to_block_number = ?
|
||||
AND rs.to_block_hash = ?
|
||||
ORDER BY LOWER(rs.from_login)
|
||||
""";
|
||||
|
||||
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, message.getBlockchainName().trim());
|
||||
ps.setInt(2, message.getBlockNumber());
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||
user.setLogin(rs.getString("from_login"));
|
||||
user.setFirstName(rs.getString("first_name"));
|
||||
user.setLastName(rs.getString("last_name"));
|
||||
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||
|
||||
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||
if (isOfficial && isShining) shining.add(user);
|
||||
else if (isOfficial) official.add(user);
|
||||
else others.add(user);
|
||||
}
|
||||
|
||||
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setShining(shining);
|
||||
resp.setOfficial(official);
|
||||
resp.setOthers(others);
|
||||
resp.setTotal(shining.size() + official.size() + others.size());
|
||||
resp.setTruncated(false);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageLikes failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -242,6 +242,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setPrimaryLikesCount(stats[3]);
|
||||
node.setShiningLikesCount(stats[4]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setRatingsCount(stats[2]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
String login = ctx.getLogin().trim();
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||
r.setOp(req.getOp());
|
||||
r.setRequestId(req.getRequestId());
|
||||
r.setStatus(WireCodes.Status.OK);
|
||||
r.setLogin(login);
|
||||
r.setDmUnreadCount(s.dmUnreadCount());
|
||||
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||
return r;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserCounters failed for {}", login, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||
row.setReadStateInitialized(readState != null);
|
||||
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
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.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import utils.config.ShineSignatureConstants;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
|
||||
/** Accepts the signed client read watermark for one followed public channel. */
|
||||
public final class Net_SetChannelReadState_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class);
|
||||
private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank()
|
||||
|| req.getChannel_name() == null || req.getChannel_name().isBlank()
|
||||
|| req.getRead_count() == null || req.getRead_count() < 0
|
||||
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|
||||
|| req.getClient_key() == null || req.getClient_key().isBlank()
|
||||
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_FIELDS", "Некорректные поля ChannelReadState");
|
||||
}
|
||||
|
||||
String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
String login = req.getLogin().trim();
|
||||
String ownerBch = req.getOwner_bch_name().trim();
|
||||
String channelName = req.getChannel_name().trim();
|
||||
long readCount = req.getRead_count();
|
||||
long timeMs = req.getTime_ms();
|
||||
String clientKeyB64 = req.getClient_key().trim();
|
||||
String signatureB64 = req.getSignature().trim();
|
||||
|
||||
if (!authenticatedLogin.equalsIgnoreCase(login)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю");
|
||||
}
|
||||
long nowMs = System.currentTimeMillis();
|
||||
if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"BAD_TIME", "Некорректное время подписи");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] pubKey32;
|
||||
byte[] sig64;
|
||||
try {
|
||||
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
||||
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_BASE64", "client_key/signature должны быть Base64");
|
||||
}
|
||||
|
||||
String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null
|
||||
? "" : ctx.getCurrentUser().getClientKey()).trim();
|
||||
if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) {
|
||||
return NetExceptionResponseFactory.error(req, 403,
|
||||
"DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||
}
|
||||
|
||||
String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX
|
||||
+ escapePart(login) + '|'
|
||||
+ escapePart(ownerBch) + '|'
|
||||
+ escapePart(channelName) + '|'
|
||||
+ timeMs + '|'
|
||||
+ readCount;
|
||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку");
|
||||
}
|
||||
|
||||
ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance();
|
||||
ChannelReadStateDAO.UpsertResult result;
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName,
|
||||
MsgSubType.CONNECTION_FOLLOW)) {
|
||||
return NetExceptionResponseFactory.error(req, 409,
|
||||
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
||||
}
|
||||
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
||||
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404,
|
||||
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||
}
|
||||
|
||||
// Never persist a watermark beyond the number of messages that really exists.
|
||||
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||
// and could make all future unread counters impossible to clear correctly.
|
||||
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||
|
||||
result = dao.upsertSignedReadIfNewer(c,
|
||||
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
||||
}
|
||||
|
||||
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(authenticatedLogin);
|
||||
resp.setOwner_bch_name(ownerBch);
|
||||
resp.setChannel_name(channelName);
|
||||
resp.setRead_count(result.readCount());
|
||||
resp.setUnread_count(result.unreadCount());
|
||||
resp.setTime_ms(timeMs);
|
||||
resp.setApplied(result.applied());
|
||||
if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("SetChannelReadState failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage(
|
||||
"Внутренняя ошибка SetChannelReadState", e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapePart(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value);
|
||||
return s.replace("\\", "\\\\").replace("|", "\\|");
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
/**
|
||||
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
|
||||
* It intentionally uses current DB state today; later it can read one materialized row/cache
|
||||
* without changing API or UI.
|
||||
*/
|
||||
public final class UserCountersSupport {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private UserCountersSupport() {}
|
||||
|
||||
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
|
||||
long connectionsUnreadCount, long eventsUnreadCount) {
|
||||
public long notificationsUnreadCount() {
|
||||
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static Snapshot calculate(Connection c, String login) throws Exception {
|
||||
long dm = countDmUnread(c, login);
|
||||
long channels = countChannelsUnread(c, login);
|
||||
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
|
||||
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
|
||||
long repliesSeen = seen.getSeenAt(c, login, "replies");
|
||||
long connectionsSeen = seen.getSeenAt(c, login, "connections");
|
||||
long eventsSeen = seen.getSeenAt(c, login, "events");
|
||||
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
|
||||
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
|
||||
long events = notifications.countUnseen(c, login, "event", eventsSeen);
|
||||
return new Snapshot(dm, channels, replies, connections, events);
|
||||
}
|
||||
|
||||
public static Snapshot calculate(String login) throws Exception {
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
return calculate(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public static void pushChanged(String login) {
|
||||
if (login == null || login.isBlank()) return;
|
||||
try {
|
||||
Snapshot s = calculate(login);
|
||||
ObjectNode payload = toPayload(login, s);
|
||||
String eventId = "user-counters-" + System.currentTimeMillis();
|
||||
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Counter push is best-effort. The client can always recover with GetUserCounters.
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectNode toPayload(String login, Snapshot s) {
|
||||
ObjectNode p = MAPPER.createObjectNode();
|
||||
p.put("login", login);
|
||||
p.put("dmUnreadCount", s.dmUnreadCount());
|
||||
p.put("channelsUnreadCount", s.channelsUnreadCount());
|
||||
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
|
||||
ObjectNode n = p.putObject("notifications");
|
||||
n.put("replies", s.repliesUnreadCount());
|
||||
n.put("connections", s.connectionsUnreadCount());
|
||||
n.put("events", s.eventsUnreadCount());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static long countDmUnread(Connection c, String login) throws Exception {
|
||||
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||
}
|
||||
}
|
||||
+6
@@ -139,6 +139,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private int primaryLikesCount;
|
||||
private int shiningLikesCount;
|
||||
private boolean likedByMe;
|
||||
private int repliesCount;
|
||||
private int ratingsCount;
|
||||
@@ -191,6 +193,10 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
|
||||
public int getLikesCount() { return likesCount; }
|
||||
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
||||
public int getPrimaryLikesCount() { return primaryLikesCount; }
|
||||
public void setPrimaryLikesCount(int primaryLikesCount) { this.primaryLikesCount = primaryLikesCount; }
|
||||
public int getShiningLikesCount() { return shiningLikesCount; }
|
||||
public void setShiningLikesCount(int shiningLikesCount) { this.shiningLikesCount = shiningLikesCount; }
|
||||
|
||||
public boolean isLikedByMe() { return likedByMe; }
|
||||
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetMessageLikes_Request extends Net_Request {
|
||||
private MessageSelector message;
|
||||
private Integer limit;
|
||||
|
||||
public MessageSelector getMessage() { return message; }
|
||||
public void setMessage(MessageSelector message) { this.message = message; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public static class MessageSelector {
|
||||
private String blockchainName;
|
||||
private Integer blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public Integer getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Response extends Net_Response {
|
||||
private List<UserItem> shining = new ArrayList<>();
|
||||
private List<UserItem> official = new ArrayList<>();
|
||||
private List<UserItem> others = new ArrayList<>();
|
||||
private int total;
|
||||
private boolean truncated;
|
||||
|
||||
public List<UserItem> getShining() { return shining; }
|
||||
public void setShining(List<UserItem> shining) { this.shining = shining; }
|
||||
public List<UserItem> getOfficial() { return official; }
|
||||
public void setOfficial(List<UserItem> official) { this.official = official; }
|
||||
public List<UserItem> getOthers() { return others; }
|
||||
public void setOthers(List<UserItem> others) { this.others = others; }
|
||||
public int getTotal() { return total; }
|
||||
public void setTotal(int total) { this.total = total; }
|
||||
public boolean isTruncated() { return truncated; }
|
||||
public void setTruncated(boolean truncated) { this.truncated = truncated; }
|
||||
|
||||
public static class UserItem {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String avatarAr;
|
||||
|
||||
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 String getAvatarAr() { return avatarAr; }
|
||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/** Lightweight authenticated request for bottom-toolbar counters. */
|
||||
public class Net_GetUserCounters_Request extends Net_Request {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
|
||||
public class Net_GetUserCounters_Response extends Net_Response {
|
||||
private String login;
|
||||
private long dmUnreadCount;
|
||||
private long channelsUnreadCount;
|
||||
private long notificationsUnreadCount;
|
||||
private long notificationRepliesUnreadCount;
|
||||
private long notificationConnectionsUnreadCount;
|
||||
private long notificationEventsUnreadCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public long getDmUnreadCount() { return dmUnreadCount; }
|
||||
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
|
||||
public long getChannelsUnreadCount() { return channelsUnreadCount; }
|
||||
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
|
||||
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
|
||||
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
|
||||
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
|
||||
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
|
||||
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
|
||||
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
|
||||
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
|
||||
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
|
||||
}
|
||||
+8
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public static class ChannelSummary {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private long readCount;
|
||||
private int unreadCount;
|
||||
private boolean readStateInitialized;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public int getMessagesCount() { return messagesCount; }
|
||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||
|
||||
public long getReadCount() { return readCount; }
|
||||
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SetChannelReadState_Request extends Net_Request {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long time_ms;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getClient_key() { return client_key; }
|
||||
public void setClient_key(String client_key) { this.client_key = client_key; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SetChannelReadState_Response extends Net_Response {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long unread_count;
|
||||
private Long time_ms;
|
||||
private Boolean applied;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getUnread_count() { return unread_count; }
|
||||
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public Boolean getApplied() { return applied; }
|
||||
public void setApplied(Boolean applied) { this.applied = applied; }
|
||||
}
|
||||
+113
-41
@@ -46,8 +46,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
|
||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> outCloseFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> inCloseFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||
@@ -60,18 +62,21 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
||||
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
||||
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
||||
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
||||
// Legacy 60/61 are accepted by the protocol but intentionally not surfaced by current UI.
|
||||
List<String> outKnownPersons = List.of();
|
||||
List<String> inKnownPersons = List.of();
|
||||
List<String> outOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, true);
|
||||
List<String> inOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, false);
|
||||
List<String> outShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, true);
|
||||
List<String> inShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, false);
|
||||
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||
|
||||
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
||||
allLogins.add(canonicalLogin);
|
||||
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
|
||||
addAllLogins(allLogins, outFriends, inFriends, outCloseFriends, inCloseFriends, outContacts, inContacts, outFollows, inFollows,
|
||||
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
|
||||
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
||||
outKnownPersons, inKnownPersons, outOfficialAccounts, inOfficialAccounts, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
||||
|
||||
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
||||
@@ -86,6 +91,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setLogin(canonicalLogin);
|
||||
resp.setOutFriends(outFriends);
|
||||
resp.setInFriends(inFriends);
|
||||
resp.setOutCloseFriends(outCloseFriends);
|
||||
resp.setInCloseFriends(inCloseFriends);
|
||||
resp.setOutContacts(outContacts);
|
||||
resp.setInContacts(inContacts);
|
||||
resp.setOutFollows(outFollows);
|
||||
@@ -100,6 +107,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setInSiblings(inSiblings);
|
||||
resp.setOutKnownPersons(outKnownPersons);
|
||||
resp.setInKnownPersons(inKnownPersons);
|
||||
resp.setOutOfficialAccounts(outOfficialAccounts);
|
||||
resp.setInOfficialAccounts(inOfficialAccounts);
|
||||
resp.setOutShineConfirmed(outShineConfirmed);
|
||||
resp.setInShineConfirmed(inShineConfirmed);
|
||||
resp.setOutShineSeen(outShineSeen);
|
||||
@@ -108,11 +117,49 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
||||
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
||||
resp.setSpouses(toRelativeItems(spouseLogins, metaByLogin));
|
||||
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin));
|
||||
Map<String, String> relationTypes = buildEffectiveRelationTypes(outContacts, outFriends, outCloseFriends);
|
||||
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin, relationTypes, outOfficialAccounts, outShineConfirmed));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> listEffectiveConfirmation(Connection c, String login, int relType, boolean outgoing) throws Exception {
|
||||
String sql;
|
||||
if (relType == 80) {
|
||||
sql = outgoing ? """
|
||||
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""" : """
|
||||
SELECT DISTINCT cs.login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""";
|
||||
} else {
|
||||
sql = outgoing ? """
|
||||
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
|
||||
AND shine_target_accepts_shine_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""" : """
|
||||
SELECT DISTINCT cs.login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
|
||||
AND shine_target_accepts_shine_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""";
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(rs.getString("peer")); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
|
||||
String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su")
|
||||
+ " WHERE LOWER(login) = LOWER(?) LIMIT 1";
|
||||
@@ -160,47 +207,54 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
private Map<String, UserMeta> loadUserMeta(Connection c, Set<String> logins) throws Exception {
|
||||
Map<String, UserMeta> out = new HashMap<>();
|
||||
if (logins == null || logins.isEmpty()) return out;
|
||||
|
||||
String[] placeholders = new String[logins.size()];
|
||||
for (int i = 0; i < placeholders.length; i += 1) placeholders[i] = "?";
|
||||
for (int i=0;i<placeholders.length;i++) placeholders[i]="?";
|
||||
String sql = """
|
||||
SELECT
|
||||
su.login AS login,
|
||||
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
|
||||
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
|
||||
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
|
||||
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
|
||||
SELECT su.login,
|
||||
COALESCE(ups.first_name,'') first_name,
|
||||
COALESCE(ups.last_name,'') last_name,
|
||||
ups.account_role, ups.shine_status, COALESCE(ups.ava_ar,'') avatar_value,
|
||||
COALESCE(ust.primary_confirmations_received_count,0) primary_confirmations_count,
|
||||
COALESCE(ust.shine_confirmations_received_count,0) shine_confirmations_count,
|
||||
MAX(CASE WHEN up.param='gender' THEN up.value END) gender_value
|
||||
FROM %s
|
||||
LEFT JOIN users_params up
|
||||
ON LOWER(up.login) = LOWER(su.login)
|
||||
AND up.param IN ('gender', 'official', 'shine', 'ava')
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login)=LOWER(su.login)
|
||||
LEFT JOIN user_stats_state ust ON LOWER(ust.login)=LOWER(su.login)
|
||||
LEFT JOIN users_params up ON LOWER(up.login)=LOWER(su.login) AND up.param='gender'
|
||||
WHERE LOWER(su.login) IN (%s)
|
||||
GROUP BY su.login
|
||||
GROUP BY su.login, ups.first_name, ups.last_name, ups.account_role, ups.shine_status, ups.ava_ar,
|
||||
ust.primary_confirmations_received_count, ust.shine_confirmations_received_count
|
||||
ORDER BY su.login
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
for (String login : logins) {
|
||||
ps.setString(i, normKey(login));
|
||||
i += 1;
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String login = rs.getString("login");
|
||||
if (login == null || login.isBlank()) continue;
|
||||
UserMeta meta = new UserMeta();
|
||||
meta.gender = normalizeGender(rs.getString("gender_value"));
|
||||
meta.official = parseToggle(rs.getString("official_value"));
|
||||
meta.shine = parseToggle(rs.getString("shine_value"));
|
||||
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
|
||||
out.put(normKey(login), meta);
|
||||
}
|
||||
}
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){
|
||||
int i=1; for(String login:logins) ps.setString(i++,normKey(login));
|
||||
try(ResultSet rs=ps.executeQuery()){while(rs.next()){
|
||||
String login=rs.getString("login"); if(login==null||login.isBlank()) continue;
|
||||
UserMeta meta=new UserMeta(); meta.firstName=rs.getString("first_name"); meta.lastName=rs.getString("last_name");
|
||||
meta.accountRole=rs.getString("account_role"); meta.shineStatus=rs.getString("shine_status");
|
||||
meta.gender=normalizeGender(rs.getString("gender_value")); meta.official="primary".equals(meta.accountRole); meta.shine="shining".equals(meta.shineStatus);
|
||||
meta.primaryConfirmationsCount=rs.getInt("primary_confirmations_count"); meta.shineConfirmationsCount=rs.getInt("shine_confirmations_count");
|
||||
meta.avatarAr=extractArAvatarTxId(rs.getString("avatar_value")); out.put(normKey(login),meta);
|
||||
}}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String,String> buildEffectiveRelationTypes(List<String> contacts,List<String> friends,List<String> closeFriends){
|
||||
Map<String,String> out=new HashMap<>();
|
||||
if(contacts!=null) for(String v:contacts) out.put(normKey(v),"contact");
|
||||
if(friends!=null) for(String v:friends) out.put(normKey(v),"friend");
|
||||
if(closeFriends!=null) for(String v:closeFriends) out.put(normKey(v),"close_friend");
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean listContainsLogin(List<String> list, String login) {
|
||||
if (list == null || login == null) return false;
|
||||
String key = normKey(login);
|
||||
for (String value : list) if (key.equals(normKey(value))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean parseToggle(String rawValue) {
|
||||
String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase();
|
||||
return "1".equals(v) || "yes".equals(v) || "true".equals(v) || "on".equals(v);
|
||||
@@ -269,7 +323,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
private List<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
|
||||
Set<String> logins,
|
||||
Map<String, UserMeta> metaByLogin
|
||||
Map<String, UserMeta> metaByLogin,
|
||||
Map<String, String> relationTypes,
|
||||
List<String> primaryConfirmedLogins,
|
||||
List<String> shineConfirmedLogins
|
||||
) {
|
||||
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> items = new ArrayList<>();
|
||||
if (logins == null) return items;
|
||||
@@ -284,6 +341,15 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
Net_GetUserConnectionsGraph_Response.UserMarkItem it = new Net_GetUserConnectionsGraph_Response.UserMarkItem();
|
||||
it.setLogin(clean);
|
||||
it.setFirstName(meta == null ? "" : meta.firstName);
|
||||
it.setLastName(meta == null ? "" : meta.lastName);
|
||||
it.setAccountRole(meta == null ? null : meta.accountRole);
|
||||
it.setShineStatus(meta == null ? null : meta.shineStatus);
|
||||
it.setRelationType(relationTypes == null ? "none" : relationTypes.getOrDefault(normKey(clean), "none"));
|
||||
it.setPrimaryConfirmed(listContainsLogin(primaryConfirmedLogins, clean));
|
||||
it.setShineConfirmed(listContainsLogin(shineConfirmedLogins, clean));
|
||||
it.setPrimaryConfirmationsCount(meta == null ? 0 : meta.primaryConfirmationsCount);
|
||||
it.setShineConfirmationsCount(meta == null ? 0 : meta.shineConfirmationsCount);
|
||||
it.setOfficial(official);
|
||||
it.setShine(shine);
|
||||
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
||||
@@ -296,6 +362,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final class UserMeta {
|
||||
private String gender = "unknown";
|
||||
private String firstName = "";
|
||||
private String lastName = "";
|
||||
private String accountRole = null;
|
||||
private String shineStatus = null;
|
||||
private int primaryConfirmationsCount = 0;
|
||||
private int shineConfirmationsCount = 0;
|
||||
private boolean official = false;
|
||||
private boolean shine = false;
|
||||
private String avatarAr = null;
|
||||
|
||||
+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.WireCodes;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
import shine.db.dao.UserProfileStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
@@ -29,18 +30,25 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(ctx.getLogin());
|
||||
resp.setDialogs(toDialogItems(dialogs));
|
||||
resp.setDialogs(toDialogItems(c, dialogs));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(Connection c, List<DmDialogStateDAO.DialogSummary> dialogs) throws Exception {
|
||||
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
|
||||
if (dialogs == null) return items;
|
||||
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
||||
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
||||
item.setPeerLogin(dialog.peerLogin());
|
||||
item.setRelationFlag(dialog.relationFlag());
|
||||
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, dialog.peerLogin());
|
||||
item.setFirstName(card.firstName());
|
||||
item.setLastName(card.lastName());
|
||||
item.setAvatarAr(card.avatarAr());
|
||||
item.setAvatar(parseAvatar(card.avatarAr()));
|
||||
item.setAccountRole(card.accountRole());
|
||||
item.setShineStatus(card.shineStatus());
|
||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
||||
item.setUnreadCount(dialog.unreadCount());
|
||||
@@ -49,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
}
|
||||
return items;
|
||||
}
|
||||
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
|
||||
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
|
||||
String raw = value == null ? "" : value.trim();
|
||||
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||
if (ar.find()) out.setAr(ar.group(1));
|
||||
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+39
@@ -9,6 +9,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> outFriends = new ArrayList<>();
|
||||
private List<String> inFriends = new ArrayList<>();
|
||||
private List<String> outCloseFriends = new ArrayList<>();
|
||||
private List<String> inCloseFriends = new ArrayList<>();
|
||||
private List<String> outContacts = new ArrayList<>();
|
||||
private List<String> inContacts = new ArrayList<>();
|
||||
private List<String> outFollows = new ArrayList<>();
|
||||
@@ -23,6 +25,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private List<String> inSiblings = new ArrayList<>();
|
||||
private List<String> outKnownPersons = new ArrayList<>();
|
||||
private List<String> inKnownPersons = new ArrayList<>();
|
||||
private List<String> outOfficialAccounts = new ArrayList<>();
|
||||
private List<String> inOfficialAccounts = new ArrayList<>();
|
||||
private List<String> outShineConfirmed = new ArrayList<>();
|
||||
private List<String> inShineConfirmed = new ArrayList<>();
|
||||
private List<String> outShineSeen = new ArrayList<>();
|
||||
@@ -58,6 +62,15 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
|
||||
public static class UserMarkItem {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
private String relationType;
|
||||
private boolean primaryConfirmed;
|
||||
private boolean shineConfirmed;
|
||||
private int primaryConfirmationsCount;
|
||||
private int shineConfirmationsCount;
|
||||
private boolean official;
|
||||
private boolean shine;
|
||||
private String officialLabel;
|
||||
@@ -66,6 +79,24 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String v) { this.firstName = v; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String v) { this.lastName = v; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String v) { this.accountRole = v; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String v) { this.shineStatus = v; }
|
||||
public String getRelationType() { return relationType; }
|
||||
public void setRelationType(String v) { this.relationType = v; }
|
||||
public boolean isPrimaryConfirmed() { return primaryConfirmed; }
|
||||
public void setPrimaryConfirmed(boolean v) { this.primaryConfirmed = v; }
|
||||
public boolean isShineConfirmed() { return shineConfirmed; }
|
||||
public void setShineConfirmed(boolean v) { this.shineConfirmed = v; }
|
||||
public int getPrimaryConfirmationsCount() { return primaryConfirmationsCount; }
|
||||
public void setPrimaryConfirmationsCount(int v) { this.primaryConfirmationsCount = v; }
|
||||
public int getShineConfirmationsCount() { return shineConfirmationsCount; }
|
||||
public void setShineConfirmationsCount(int v) { this.shineConfirmationsCount = v; }
|
||||
public boolean isOfficial() { return official; }
|
||||
public void setOfficial(boolean official) { this.official = official; }
|
||||
public boolean isShine() { return shine; }
|
||||
@@ -84,6 +115,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
|
||||
public List<String> getInFriends() { return inFriends; }
|
||||
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
|
||||
public List<String> getOutCloseFriends() { return outCloseFriends; }
|
||||
public void setOutCloseFriends(List<String> outCloseFriends) { this.outCloseFriends = outCloseFriends; }
|
||||
public List<String> getInCloseFriends() { return inCloseFriends; }
|
||||
public void setInCloseFriends(List<String> inCloseFriends) { this.inCloseFriends = inCloseFriends; }
|
||||
public List<String> getOutContacts() { return outContacts; }
|
||||
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
|
||||
public List<String> getInContacts() { return inContacts; }
|
||||
@@ -112,6 +147,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
|
||||
public List<String> getInKnownPersons() { return inKnownPersons; }
|
||||
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
|
||||
public List<String> getOutOfficialAccounts() { return outOfficialAccounts; }
|
||||
public void setOutOfficialAccounts(List<String> outOfficialAccounts) { this.outOfficialAccounts = outOfficialAccounts; }
|
||||
public List<String> getInOfficialAccounts() { return inOfficialAccounts; }
|
||||
public void setInOfficialAccounts(List<String> inOfficialAccounts) { this.inOfficialAccounts = inOfficialAccounts; }
|
||||
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
|
||||
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
|
||||
public List<String> getInShineConfirmed() { return inShineConfirmed; }
|
||||
|
||||
+27
@@ -17,6 +17,12 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public static class DialogItem {
|
||||
private String peerLogin;
|
||||
private String relationFlag;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String avatarAr;
|
||||
private Avatar avatar;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
private String lastMessageBlobB64;
|
||||
private long lastMessageTimeMs;
|
||||
private int unreadCount;
|
||||
@@ -26,6 +32,18 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public String getRelationFlag() { return relationFlag; }
|
||||
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public String getAvatarAr() { return avatarAr; }
|
||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||
public Avatar getAvatar() { return avatar; }
|
||||
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
|
||||
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
||||
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
||||
@@ -35,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public boolean isHasDialog() { return hasDialog; }
|
||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||
}
|
||||
|
||||
public static class Avatar {
|
||||
private String ar;
|
||||
private String sha256Hex;
|
||||
public String getAr() { return ar; }
|
||||
public void setAr(String ar) { this.ar = ar; }
|
||||
public String getSha256Hex() { return sha256Hex; }
|
||||
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||
}
|
||||
}
|
||||
|
||||
+22
-89
@@ -1,94 +1,27 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController; import shine.db.dao.*; import shine.db.entities.UserNotificationEntry;
|
||||
import java.sql.Connection; import java.util.*;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); private static final long HISTORY_MS=60L*24*60*60*1000;
|
||||
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||
Net_GetNotifications_Request req=(Net_GetNotifications_Request)base; if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Операция доступна только для авторизованных пользователей");
|
||||
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
try(Connection c=DbController.getInstance().getConnection()){
|
||||
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||
r.setReplies(map(nd.listVisible(c,login,"reply",rs,cutoff))); r.setConnections(map(nd.listVisible(c,login,"connection",cs,cutoff))); r.setEvents(map(nd.listVisible(c,login,"event",es,cutoff)));
|
||||
}
|
||||
r.setRepliesSeenAtMs(rs);r.setConnectionsSeenAtMs(cs);r.setEventsSeenAtMs(es); r.setRepliesUnseenCount(nd.countUnseen(c,login,"reply",rs)); r.setConnectionsUnseenCount(nd.countUnseen(c,login,"connection",cs)); r.setEventsUnseenCount(nd.countUnseen(c,login,"event",es));
|
||||
return r;
|
||||
}catch(Exception e){log.error("GetNotifications failed",e);return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"internal_error","Внутренняя ошибка сервера");}
|
||||
}
|
||||
private List<Net_GetNotifications_Response.NotificationItem> map(List<UserNotificationEntry> rows){ List<Net_GetNotifications_Response.NotificationItem> out=new ArrayList<>(); for(UserNotificationEntry x:rows){ Net_GetNotifications_Response.NotificationItem i=new Net_GetNotifications_Response.NotificationItem(); i.setKind(x.getNotificationKind());i.setCreatedAtMs(x.getCreatedAtMs());i.setSourceLogin(x.getSourceLogin());i.setSourceBlockchainName(x.getSourceBchName());i.setSourceBlockNumber(x.getSourceBlockNumber());i.setSourceBlockHash(hex(x.getSourceBlockHash()));i.setSourceMsgSubType(x.getSourceMsgSubType());i.setConnectionTypeCode("connection".equals(x.getNotificationKind())?x.getSourceMsgSubType():null);i.setSourceText(x.getSourceText());i.setTargetLogin(x.getTargetLogin());i.setTargetBlockchainName(x.getTargetBchName());i.setTargetBlockNumber(x.getTargetBlockNumber());i.setTargetBlockHash(hex(x.getTargetBlockHash()));out.add(i);} return out; }
|
||||
private static String hex(byte[] b){if(b==null)return null;StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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.handlers.channels.UserCountersSupport;
|
||||
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); UserCountersSupport.pushChanged(login); return r;
|
||||
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
final class NotificationStatePacket {
|
||||
static final byte[] PREFIX = "SHiNE_NTF".getBytes(StandardCharsets.US_ASCII);
|
||||
static final int STATE_SEEN_WATERMARK = 1;
|
||||
static final int CATEGORY_REPLIES = 1;
|
||||
static final int CATEGORY_CONNECTIONS = 2;
|
||||
static final int CATEGORY_EVENTS = 3;
|
||||
|
||||
final String login; final long timeMs; final long nonce; final int stateType; final int category; final long seenAtMs;
|
||||
final byte[] signedBody; final byte[] signature64; final byte[] rawPacket;
|
||||
private NotificationStatePacket(String login,long timeMs,long nonce,int stateType,int category,long seenAtMs,byte[] signedBody,byte[] signature64,byte[] rawPacket){
|
||||
this.login=login;this.timeMs=timeMs;this.nonce=nonce;this.stateType=stateType;this.category=category;this.seenAtMs=seenAtMs;this.signedBody=signedBody;this.signature64=signature64;this.rawPacket=rawPacket;
|
||||
}
|
||||
static NotificationStatePacket parse(byte[] raw) {
|
||||
if(raw==null||raw.length<PREFIX.length+2+1+1+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LEN");
|
||||
for(int i=0;i<PREFIX.length;i++) if(raw[i]!=PREFIX[i]) throw new IllegalArgumentException("BAD_PREFIX");
|
||||
ByteBuffer bb=ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); bb.position(PREFIX.length);
|
||||
int major=Byte.toUnsignedInt(bb.get()), minor=Byte.toUnsignedInt(bb.get());
|
||||
if(major!=1||minor!=0) throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||
int len=Byte.toUnsignedInt(bb.get()); if(len<1||len>60||bb.remaining()<len+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LOGIN");
|
||||
byte[] lb=new byte[len]; bb.get(lb); for(byte b:lb) if(b<0x20||b>0x7e) throw new IllegalArgumentException("BAD_LOGIN");
|
||||
String login=new String(lb,StandardCharsets.US_ASCII); long timeMs=bb.getLong(); if(timeMs<0) throw new IllegalArgumentException("BAD_TIME");
|
||||
long nonce=Integer.toUnsignedLong(bb.getInt()); int stateType=Byte.toUnsignedInt(bb.get()); if(stateType!=STATE_SEEN_WATERMARK) throw new IllegalArgumentException("BAD_STATE_TYPE");
|
||||
int category=Byte.toUnsignedInt(bb.get()); if(category<1||category>3) throw new IllegalArgumentException("BAD_CATEGORY");
|
||||
long seenAtMs=bb.getLong(); if(seenAtMs<0||bb.remaining()!=64) throw new IllegalArgumentException("BAD_SEEN_TIME");
|
||||
byte[] sig=new byte[64]; bb.get(sig); return new NotificationStatePacket(login,timeMs,nonce,stateType,category,seenAtMs,Arrays.copyOf(raw,raw.length-64),sig,raw);
|
||||
}
|
||||
String categoryName(){ return category==CATEGORY_REPLIES?"replies":category==CATEGORY_CONNECTIONS?"connections":"events"; }
|
||||
}
|
||||
+4
-2
@@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
private Integer limit; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
|
||||
private Boolean countsOnly;
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
public Boolean getCountsOnly() { return countsOnly; }
|
||||
public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; }
|
||||
}
|
||||
|
||||
+11
@@ -8,12 +8,23 @@ import java.util.List;
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> connections = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getConnections() { return connections; }
|
||||
public void setConnections(List<NotificationItem> v) { connections = v; }
|
||||
public long getRepliesSeenAtMs(){return repliesSeenAtMs;} public void setRepliesSeenAtMs(long v){repliesSeenAtMs=v;}
|
||||
public long getConnectionsSeenAtMs(){return connectionsSeenAtMs;} public void setConnectionsSeenAtMs(long v){connectionsSeenAtMs=v;}
|
||||
public long getEventsSeenAtMs(){return eventsSeenAtMs;} public void setEventsSeenAtMs(long v){eventsSeenAtMs=v;}
|
||||
public long getRepliesUnseenCount(){return repliesUnseenCount;} public void setRepliesUnseenCount(long v){repliesUnseenCount=v;}
|
||||
public long getConnectionsUnseenCount(){return connectionsUnseenCount;} public void setConnectionsUnseenCount(long v){connectionsUnseenCount=v;}
|
||||
public long getEventsUnseenCount(){return eventsUnseenCount;} public void setEventsUnseenCount(long v){eventsUnseenCount=v;}
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_SetNotificationState_Request extends Net_Request { private String blobB64; public String getBlobB64(){return blobB64;} public void setBlobB64(String v){blobB64=v;} }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
public class Net_SetNotificationState_Response extends Net_Response { private String category; private long seenAtMs; public String getCategory(){return category;} public void setCategory(String v){category=v;} public long getSeenAtMs(){return seenAtMs;} public void setSeenAtMs(long v){seenAtMs=v;} }
|
||||
+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.setFollowingChannelsCount(0);
|
||||
resp.setCloseFriendsCount(0);
|
||||
resp.setFriendsCount(0);
|
||||
resp.setPrimaryConfirmationsReceivedCount(0);
|
||||
resp.setPrimaryConfirmationsGivenCount(0);
|
||||
resp.setShineConfirmationsReceivedCount(0);
|
||||
resp.setShineConfirmationsGivenCount(0);
|
||||
String sql = """
|
||||
SELECT owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count
|
||||
close_friends_count,
|
||||
friends_count,
|
||||
primary_confirmations_received_count,
|
||||
primary_confirmations_given_count,
|
||||
shine_confirmations_received_count,
|
||||
shine_confirmations_given_count
|
||||
FROM user_stats_state
|
||||
WHERE login = ?
|
||||
LIMIT 1
|
||||
@@ -156,6 +166,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
||||
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
|
||||
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
|
||||
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
|
||||
resp.setFriendsCount(rs.getInt("friends_count"));
|
||||
resp.setPrimaryConfirmationsReceivedCount(rs.getInt("primary_confirmations_received_count"));
|
||||
resp.setPrimaryConfirmationsGivenCount(rs.getInt("primary_confirmations_given_count"));
|
||||
resp.setShineConfirmationsReceivedCount(rs.getInt("shine_confirmations_received_count"));
|
||||
resp.setShineConfirmationsGivenCount(rs.getInt("shine_confirmations_given_count"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
|
||||
|
||||
+15
@@ -47,6 +47,11 @@ public class Net_GetUser_Response extends Net_Response {
|
||||
private Integer followingUsersCount;
|
||||
private Integer followingChannelsCount;
|
||||
private Integer closeFriendsCount;
|
||||
private Integer friendsCount;
|
||||
private Integer primaryConfirmationsReceivedCount;
|
||||
private Integer primaryConfirmationsGivenCount;
|
||||
private Integer shineConfirmationsReceivedCount;
|
||||
private Integer shineConfirmationsGivenCount;
|
||||
|
||||
public Boolean getExists() { return exists; }
|
||||
public void setExists(Boolean exists) { this.exists = exists; }
|
||||
@@ -89,5 +94,15 @@ public class Net_GetUser_Response extends Net_Response {
|
||||
|
||||
public Integer getCloseFriendsCount() { return closeFriendsCount; }
|
||||
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
|
||||
public Integer getFriendsCount() { return friendsCount; }
|
||||
public void setFriendsCount(Integer friendsCount) { this.friendsCount = friendsCount; }
|
||||
public Integer getPrimaryConfirmationsReceivedCount() { return primaryConfirmationsReceivedCount; }
|
||||
public void setPrimaryConfirmationsReceivedCount(Integer v) { this.primaryConfirmationsReceivedCount = v; }
|
||||
public Integer getPrimaryConfirmationsGivenCount() { return primaryConfirmationsGivenCount; }
|
||||
public void setPrimaryConfirmationsGivenCount(Integer v) { this.primaryConfirmationsGivenCount = v; }
|
||||
public Integer getShineConfirmationsReceivedCount() { return shineConfirmationsReceivedCount; }
|
||||
public void setShineConfirmationsReceivedCount(Integer v) { this.shineConfirmationsReceivedCount = v; }
|
||||
public Integer getShineConfirmationsGivenCount() { return shineConfirmationsGivenCount; }
|
||||
public void setShineConfirmationsGivenCount(Integer v) { this.shineConfirmationsGivenCount = v; }
|
||||
|
||||
}
|
||||
|
||||
+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.WireCodes;
|
||||
import shine.db.dao.SignedMessagesDAO;
|
||||
import shine.db.dao.UserProfileStateDAO;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import shine.db.dao.DmDeliveryStateDAO;
|
||||
import shine.db.entities.DmDeliveryStateEntry;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
@@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||
resp.setLimit(limit);
|
||||
resp.setHasMore(hasMore);
|
||||
|
||||
// Return a normalized peer card together with the dialog. This makes the
|
||||
// chat header self-contained even when /chat/<login> is opened directly.
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin);
|
||||
Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard();
|
||||
peer.setLogin(peerLogin);
|
||||
peer.setFirstName(card.firstName());
|
||||
peer.setLastName(card.lastName());
|
||||
peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin));
|
||||
peer.setAccountRole(card.accountRole());
|
||||
peer.setShineStatus(card.shineStatus());
|
||||
peer.setAvatar(parseAvatar(card.avatarAr()));
|
||||
resp.setPeer(peer);
|
||||
}
|
||||
|
||||
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||
for (SignedMessageEntry entry : page) {
|
||||
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||
@@ -108,4 +127,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) {
|
||||
Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar();
|
||||
String raw = value == null ? "" : value.trim();
|
||||
|
||||
// Do not call Matcher.find() twice on the same matcher: the first successful
|
||||
// call advances it and the second one can make a perfectly valid avatar vanish.
|
||||
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||
if (ar.find()) {
|
||||
out.setAr(ar.group(1));
|
||||
}
|
||||
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||
if (sha.find()) {
|
||||
out.setSha256Hex(sha.group(1).toLowerCase());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+3
@@ -4,6 +4,7 @@ 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.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (status.applied()) {
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||
|
||||
+9
@@ -4,6 +4,7 @@ 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.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairStatus.applied()) {
|
||||
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
|
||||
// SendMessagePair is also the local-server delivery path. Without this push,
|
||||
// a recipient on the same access server receives the DM but the toolbar
|
||||
// counter stays stale until the next explicit GetUserCounters refresh.
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
}
|
||||
|
||||
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
|
||||
+38
@@ -12,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
||||
private boolean hasMore;
|
||||
private Long nextBeforeTimeMs;
|
||||
private String nextBeforeMessageKey;
|
||||
private PeerCard peer;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
@@ -26,9 +27,46 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
||||
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||
public PeerCard getPeer() { return peer; }
|
||||
public void setPeer(PeerCard peer) { this.peer = peer; }
|
||||
public List<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||
|
||||
|
||||
public static class PeerCard {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private Avatar avatar;
|
||||
private String relationType;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public Avatar getAvatar() { return avatar; }
|
||||
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||
public String getRelationType() { return relationType; }
|
||||
public void setRelationType(String relationType) { this.relationType = relationType; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||
}
|
||||
|
||||
public static class Avatar {
|
||||
private String ar;
|
||||
private String sha256Hex;
|
||||
public String getAr() { return ar; }
|
||||
public void setAr(String ar) { this.ar = ar; }
|
||||
public String getSha256Hex() { return sha256Hex; }
|
||||
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||
}
|
||||
|
||||
public static class MessageItem {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.10.0
|
||||
server.version=1.8.0
|
||||
client.version=1.12.12
|
||||
server.version=1.10.4
|
||||
|
||||
Executable
+317
@@ -0,0 +1,317 @@
|
||||
#!/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
|
||||
|
||||
local extracted_dir=""
|
||||
if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then
|
||||
extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)"
|
||||
fi
|
||||
if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then
|
||||
local generated_zip="$TMP/gradle-offline.zip"
|
||||
(
|
||||
cd "$(dirname -- "$extracted_dir")"
|
||||
rm -f -- "$generated_zip"
|
||||
zip -qr "$generated_zip" "gradle-8.14"
|
||||
)
|
||||
if [[ -f "$generated_zip" ]]; then
|
||||
printf '%s\n' "$generated_zip"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
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, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&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}')"
|
||||
@@ -14,18 +14,24 @@
|
||||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||
предки, фокус-сообщение, потомки.
|
||||
|
||||
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||
4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению,
|
||||
сгруппированные для UI по статусу профиля.
|
||||
|
||||
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||
|
||||
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
|
||||
|
||||
8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
|
||||
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`:
|
||||
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,6 +112,9 @@
|
||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 90,
|
||||
"readCount": 0,
|
||||
"unreadCount": 0,
|
||||
"readStateInitialized": false,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||
"text": "актуальный текст",
|
||||
@@ -140,6 +149,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
@@ -265,7 +276,63 @@
|
||||
|
||||
---
|
||||
|
||||
## 4) GetPersonalDiary
|
||||
## 4) GetMessageLikes
|
||||
|
||||
Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
|
||||
|
||||
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
|
||||
- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
|
||||
- Пользователи группируются по состоянию профиля:
|
||||
- `shining` — `account_role=primary` и `shine_status=shining`;
|
||||
- `official` — `account_role=primary`, но без `shine_status=shining`;
|
||||
- `others` — остальные пользователи.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "req-4",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "bob-001",
|
||||
"blockNumber": 140,
|
||||
"blockHash": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "req-4",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"shining": [
|
||||
{ "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." }
|
||||
],
|
||||
"official": [],
|
||||
"others": [
|
||||
{ "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" }
|
||||
],
|
||||
"total": 2,
|
||||
"truncated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение.
|
||||
- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока.
|
||||
- `internal_error` — внутренняя ошибка чтения.
|
||||
|
||||
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
|
||||
|
||||
---
|
||||
|
||||
## 5) GetPersonalDiary
|
||||
|
||||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||
|
||||
@@ -288,7 +355,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 4) GetChannelsCounters
|
||||
## 6) GetChannelsCounters
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -320,7 +387,69 @@
|
||||
|
||||
---
|
||||
|
||||
## 5) ListGroupChats200
|
||||
## 7) SetChannelReadState
|
||||
|
||||
Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан.
|
||||
|
||||
- Требует авторизованное WebSocket-соединение.
|
||||
- `login` должен совпадать с текущим авторизованным пользователем.
|
||||
- Подпись строится client key пользователя по строке:
|
||||
|
||||
```text
|
||||
SHiNe/ChannelReadState:<login>|<owner_bch_name>|<channel_name>|<time_ms>|<read_count>
|
||||
```
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "req-7",
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"owner_bch_name": "bob-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 90,
|
||||
"time_ms": 1760000000000,
|
||||
"client_key": "<base64-client-public-key>",
|
||||
"signature": "<base64-ed25519-signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "req-7",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"owner_bch_name": "bob-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 90,
|
||||
"unread_count": 0,
|
||||
"time_ms": 1760000000000,
|
||||
"applied": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
- `NOT_AUTHENTICATED` — нет авторизованной сессии.
|
||||
- `BAD_FIELDS` — не переданы обязательные поля.
|
||||
- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией.
|
||||
- `BAD_TIME` — время подписи слишком далеко в будущем.
|
||||
- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64.
|
||||
- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя.
|
||||
- `INVALID_SIGNATURE` — подпись watermark не прошла проверку.
|
||||
- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал.
|
||||
- `CHANNEL_NOT_FOUND` — канал не найден.
|
||||
- `INTERNAL_ERROR` — внутренняя ошибка записи.
|
||||
|
||||
---
|
||||
|
||||
## 8) ListGroupChats200
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -360,7 +489,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 6) GetGroupDialog
|
||||
## 9) GetGroupDialog
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -413,6 +542,36 @@
|
||||
- `user_not_found`
|
||||
- `channel_not_found`
|
||||
- `message_not_found`
|
||||
- `limit_too_large`
|
||||
- `bad_limit`
|
||||
- `channel_name_already_exists`
|
||||
- `CHANNEL_NOT_FOLLOWED`
|
||||
- `CHANNEL_NOT_FOUND`
|
||||
- `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.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||
- `GetChannelMessages` — сообщения конкретного канала.
|
||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
|
||||
- `SetChannelReadState` — подписанный watermark чтения канала.
|
||||
|
||||
2. **UI вкладки Каналы**:
|
||||
- при открытии пытается загрузить реальный feed с сервера;
|
||||
@@ -33,12 +35,16 @@
|
||||
1. Вызвать `ListSubscriptionsFeed`.
|
||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||
4. Для первого `messages[0]` вызвать `GetMessageLikes`.
|
||||
5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`.
|
||||
|
||||
### Ошибки
|
||||
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||
4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`.
|
||||
5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`.
|
||||
6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,6 +97,38 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 3.4 GetMessageLikes
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "debug-likes-1",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "TestUser1-001",
|
||||
"blockNumber": 123,
|
||||
"blockHash": "<hash-from-GetChannelMessages>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.5 SetChannelReadState
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "debug-read-state-1",
|
||||
"payload": {
|
||||
"login": "TestUser1",
|
||||
"owner_bch_name": "TestUser2-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 25,
|
||||
"time_ms": 1760000000000,
|
||||
"client_key": "<base64-client-public-key>",
|
||||
"signature": "<base64-ed25519-signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Что смотреть в ответах
|
||||
@@ -101,6 +139,8 @@
|
||||
- у каждой записи есть:
|
||||
- `channel.channelRoot.blockNumber`,
|
||||
- `messagesCount`,
|
||||
- `unreadCount`,
|
||||
- `readStateInitialized`,
|
||||
- `lastMessage` (может быть null, если сообщений нет).
|
||||
|
||||
### GetChannelMessages
|
||||
@@ -115,6 +155,16 @@
|
||||
- у узлов должны быть версии и счетчики.
|
||||
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
|
||||
|
||||
### GetMessageLikes
|
||||
- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей.
|
||||
- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`.
|
||||
- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации.
|
||||
|
||||
### SetChannelReadState
|
||||
- `payload.read_count` — сохраненная позиция чтения.
|
||||
- `payload.unread_count` — пересчитанный unread для канала.
|
||||
- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция.
|
||||
|
||||
### Важно по совместимости
|
||||
- `rawBlockB64` добавлен только в `GetMessageThread`.
|
||||
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
|
||||
|
||||
@@ -46,8 +46,10 @@
|
||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
||||
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
|
||||
| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению |
|
||||
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
@@ -60,7 +62,8 @@
|
||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
||||
| `GetNotifications` | `15_Notifications_API.md` | ответы, связи, события и unread-watermark |
|
||||
| `SetNotificationState` | `15_Notifications_API.md` | подписанное состояние просмотра уведомлений |
|
||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
|
||||
@@ -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 для разработчиков: уведомления
|
||||
|
||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
||||
Уведомления являются серверной проекцией событий блокчейна. Сервер возвращает все непросмотренные записи независимо от возраста и просмотренные записи не старше 60 дней. Пагинации нет: выдача содержит все непросмотренные и всю доступную 60-дневную просмотренную историю.
|
||||
|
||||
Текущая операция:
|
||||
## GetNotifications
|
||||
|
||||
- `GetNotifications`
|
||||
Авторизация обязательна. Обычно payload пустой. Legacy-поле `limit` принимается для совместимости, но в v2 игнорируется. Для обновления badge без загрузки карточек можно передать `{"countsOnly":true}`; тогда массивы лент остаются пустыми, но watermark и `*UnseenCount` возвращаются.
|
||||
|
||||
## 1. `GetNotifications`
|
||||
Ответ содержит три ленты: `replies`, `connections`, `events`, а также `*SeenAtMs` и `*UnseenCount` для каждой категории.
|
||||
|
||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
||||
- `replies`: TEXT_REPLY.
|
||||
- `connections`: friend/unfriend, close_friend/unclose_friend, shine confirmed/unconfirmed, official confirmed/unconfirmed. Контакты не создают уведомлений.
|
||||
- `events`: FOLLOW/UNFOLLOW каналов.
|
||||
|
||||
Возвращаются две отдельные ленты:
|
||||
Фильтр каждой категории: `created_at_ms > seenAtMs OR created_at_ms >= now - 60 days`.
|
||||
|
||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
||||
- `events` — события добавления в `close_friend`.
|
||||
## SetNotificationState
|
||||
|
||||
### Запрос
|
||||
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
|
||||
|
||||
Запрос:
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetNotifications",
|
||||
"requestId": "notif-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"replies": [
|
||||
{
|
||||
"kind": "reply",
|
||||
"createdAtMs": 1755673200000,
|
||||
"sourceLogin": "Bob",
|
||||
"sourceBlockchainName": "bob-001",
|
||||
"sourceBlockNumber": 42,
|
||||
"sourceBlockHash": "ab12...",
|
||||
"sourceMsgSubType": 20,
|
||||
"sourceText": "Спасибо!",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 18,
|
||||
"targetBlockHash": "cd34..."
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"kind": "close_friend",
|
||||
"createdAtMs": 1755673300000,
|
||||
"sourceLogin": "Kate",
|
||||
"sourceBlockchainName": "kate-001",
|
||||
"sourceBlockNumber": 7,
|
||||
"sourceBlockHash": "ef56...",
|
||||
"sourceMsgSubType": 10,
|
||||
"sourceText": "close_friend",
|
||||
"targetLogin": "Alice",
|
||||
"targetBlockchainName": "alice-001",
|
||||
"targetBlockNumber": 0,
|
||||
"targetBlockHash": "0000..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```text
|
||||
'SHiNE_NTF' 9 bytes ASCII
|
||||
formatVersionMajor u8 = 1
|
||||
formatVersionMinor u8 = 0
|
||||
loginLen u8
|
||||
login ASCII[loginLen]
|
||||
timeMs u64
|
||||
nonce u32
|
||||
stateType u8 = 1 (SEEN_WATERMARK)
|
||||
category u8 (1 replies, 2 connections, 3 events)
|
||||
seenAtMs u64
|
||||
signature Ed25519[64]
|
||||
```
|
||||
|
||||
### Примечание
|
||||
|
||||
- `replies` заполняется только для `TEXT_REPLY`.
|
||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
||||
- Другие типы связей в эту ленту не попадают.
|
||||
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# User counters API
|
||||
|
||||
## GetUserCounters
|
||||
Authenticated lightweight WebSocket request used by the bottom toolbar.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{"op":"GetUserCounters","requestId":"...","payload":{}}
|
||||
```
|
||||
|
||||
Success payload:
|
||||
```json
|
||||
{
|
||||
"login": "alice",
|
||||
"dmUnreadCount": 7,
|
||||
"channelsUnreadCount": 12,
|
||||
"notificationsUnreadCount": 5,
|
||||
"notificationRepliesUnreadCount": 2,
|
||||
"notificationConnectionsUnreadCount": 1,
|
||||
"notificationEventsUnreadCount": 2
|
||||
}
|
||||
```
|
||||
|
||||
The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later.
|
||||
|
||||
## UserCountersChanged
|
||||
Server push event for every active session of the user. It lets the toolbar update without polling.
|
||||
|
||||
```json
|
||||
{
|
||||
"op":"UserCountersChanged",
|
||||
"event":true,
|
||||
"payload":{
|
||||
"login":"alice",
|
||||
"dmUnreadCount":7,
|
||||
"channelsUnreadCount":12,
|
||||
"notificationsUnreadCount":5,
|
||||
"notifications":{"replies":2,"connections":1,"events":2}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement.
|
||||
|
||||
## Channel read behaviour
|
||||
- Opening a subscribed channel does **not** mark all messages read.
|
||||
- The UI advances readCount only when message cards cross the viewport read threshold while scrolling.
|
||||
- Writes are debounced; failed writes are shown to the user and retried.
|
||||
- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time.
|
||||
@@ -131,6 +131,12 @@ dm_dialog_state хранит:
|
||||
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
||||
историю у своего единственного access-сервера.
|
||||
|
||||
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
|
||||
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
|
||||
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
|
||||
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
|
||||
видимого чтения/receipt отмечает его прочитанным.
|
||||
|
||||
## 9. Подтверждения прочтения
|
||||
|
||||
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||
@@ -203,3 +209,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
||||
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||
|
||||
## Мультипрофильный клиент (UI, 2026-09-03)
|
||||
- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM.
|
||||
- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна.
|
||||
- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие.
|
||||
- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным.
|
||||
- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались.
|
||||
|
||||
## UI: видимость пустого диалога после DeleteConversation
|
||||
|
||||
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
|
||||
|
||||
Для списка личных чатов действует правило:
|
||||
|
||||
- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается;
|
||||
- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи;
|
||||
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
|
||||
|
||||
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
|
||||
|
||||
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
|
||||
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
|
||||
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
|
||||
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
|
||||
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
@@ -356,3 +362,12 @@ ReadReceiptBody_v1_0
|
||||
|
||||
## Примечание UI списка чатов (2026-08-28)
|
||||
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||
|
||||
## Примечание о мультипрофиле (2026-09-03)
|
||||
Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`).
|
||||
|
||||
## UI-семантика `type=7/8` в списке диалогов
|
||||
|
||||
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||
|
||||
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
# Личный дневник — временно отключён в UI
|
||||
|
||||
Статус: **закомментировано / скрыто из интерфейса**.
|
||||
|
||||
Личный дневник SHiNE не удалён из протокола и серверной части. На текущем этапе он только перестал отображаться пользователю в списке каналов.
|
||||
|
||||
## Что это за функция
|
||||
|
||||
«Дневник» — виртуальный персональный канал пользователя. Он формируется не как обычный канал с отдельной цепочкой постов, а собирается сервером из `STATUS_ACTION` записей пользователя.
|
||||
|
||||
Основной read API:
|
||||
|
||||
- `GetPersonalDiary`
|
||||
|
||||
Связанная документация:
|
||||
|
||||
- `docs/API/06_Channels_Read_API.md` — раздел `GetPersonalDiary`;
|
||||
- `docs/API/09_Operations_Index.md` — операция `GetPersonalDiary`;
|
||||
- `docs/Blockchain/15_STATUS_ACTION_Blocks.md` — действия, из которых собирается дневник;
|
||||
- `docs/Blockchain/CHANGELOG.md` — история добавления функции.
|
||||
|
||||
## Что отключено сейчас
|
||||
|
||||
В `shine-UI/js/pages/channels-list.js` отключён запрос `authService.getPersonalDiary(...)` при построении списка каналов.
|
||||
|
||||
Код оставлен рядом в комментариях, а в `mapApiFeed(...)` передаётся `diaryPayload = null`. Благодаря этому карточка «Дневник» не появляется в разделе каналов.
|
||||
|
||||
## Что намеренно НЕ удалено
|
||||
|
||||
Чтобы не ломать обратную совместимость и сохранить возможность вернуть функцию позже, оставлены:
|
||||
|
||||
- серверная операция `GetPersonalDiary`;
|
||||
- `authService.getPersonalDiary(...)`;
|
||||
- обработка diary-route в `channel-view.js`;
|
||||
- форматы `STATUS_ACTION`;
|
||||
- чтение старых типов сообщений и действий.
|
||||
|
||||
То есть функция сохранена технически, но скрыта из обычной навигации.
|
||||
|
||||
## Как вернуть дневник
|
||||
|
||||
В `shine-UI/js/pages/channels-list.js` вернуть получение `diaryPayload`:
|
||||
|
||||
```js
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
} catch {
|
||||
diaryPayload = null;
|
||||
}
|
||||
```
|
||||
|
||||
и убрать временное:
|
||||
|
||||
```js
|
||||
const diaryPayload = null;
|
||||
```
|
||||
|
||||
После этого `mapApiFeed(...)` снова сможет добавить виртуальную карточку дневника в список каналов.
|
||||
|
||||
## Связанные типы контента
|
||||
|
||||
В интерфейсе создания новой записи канала сейчас доступны только:
|
||||
|
||||
- `POST (10)` — Пост;
|
||||
- `TEXT_ENTRYPOINT (100)` — Оглавление канала.
|
||||
|
||||
Варианты `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)` и `TEXT_COURSE (130)` убраны только из формы создания новой записи. Их константы и обработчики чтения сохранены для совместимости со старыми данными.
|
||||
+1278
-37
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="14" y1="10" x2="52" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#56C7FF"/>
|
||||
<stop offset="0.48" stop-color="#1687F8"/>
|
||||
<stop offset="1" stop-color="#075EE8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ring" x1="10" y1="8" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8EDFF"/>
|
||||
<stop offset="0.5" stop-color="#4BC7FF"/>
|
||||
<stop offset="1" stop-color="#2473FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="32" cy="32" r="29" fill="url(#bg)"/>
|
||||
<circle cx="32" cy="32" r="28" stroke="url(#ring)" stroke-width="2"/>
|
||||
<circle cx="32" cy="32" r="24.5" stroke="#BCEEFF" stroke-opacity="0.72" stroke-width="1.5"/>
|
||||
|
||||
<path d="M19.5 32.7L27.7 40.9L45 23.6"
|
||||
stroke="white"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
@@ -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 existing = allClients.find((client) => {
|
||||
try {
|
||||
return client.url.includes('/index.html') || client.url.endsWith('/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const existing = allClients[0] || null;
|
||||
|
||||
const openUrlBase = './index.html';
|
||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||
const openUrl = (action === 'accept' || action === 'decline')
|
||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||
: openUrlBase;
|
||||
: `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
} else {
|
||||
existing.postMessage({
|
||||
type: 'SHINE_NOTIFICATION_CLICK',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
await existing.focus();
|
||||
|
||||
+4
-2
@@ -18,7 +18,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -43,7 +43,9 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<div id="topbar-slot" class="topbar-slot" hidden></div>
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="composer-slot" class="composer-slot" hidden></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot" hidden></div>
|
||||
<div class="app-shell-fade app-shell-fade--top" aria-hidden="true"></div>
|
||||
<div class="app-shell-fade app-shell-fade--bottom" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script>
|
||||
|
||||
+245
-37
@@ -33,6 +33,9 @@ import {
|
||||
addAppLogEntry,
|
||||
authorizeSession,
|
||||
hydrateMessagesFromStore,
|
||||
getSavedProfiles,
|
||||
closeSavedProfile,
|
||||
switchToSavedProfile,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setSessionAuthorizedHandler,
|
||||
@@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
@@ -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 chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as userProfileListView from './pages/user-profile-list-view.js';
|
||||
import * as userRelationManageView from './pages/user-relation-manage-view.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
@@ -130,6 +136,7 @@ const routes = {
|
||||
queue: publicSupportQueueView,
|
||||
'profile-view': profileView,
|
||||
'profile-edit-view': profileEditView,
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'access-servers-view': accessServersView,
|
||||
@@ -154,6 +161,8 @@ const routes = {
|
||||
'contact-search-view': contactSearchView,
|
||||
'chat-view': chatView,
|
||||
user: userProfileView,
|
||||
'user-profile-list-view': userProfileListView,
|
||||
'user-relation-manage-view': userRelationManageView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'channel-about-view': channelAboutView,
|
||||
@@ -209,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||
'settings-view',
|
||||
'profiles-view',
|
||||
]);
|
||||
|
||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||
@@ -389,14 +399,88 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
syncDebug();
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName) {
|
||||
const MANAGED_SHELL_CLASSES = [
|
||||
'app-shell--top-fade',
|
||||
'app-shell--bottom-fade',
|
||||
'app-shell--bottom-fade-composer',
|
||||
'app-shell--bottom-fade-toolbar',
|
||||
'app-shell--fade-edge',
|
||||
'app-shell--content-under-topbar',
|
||||
'app-shell--content-under-bottom',
|
||||
'app-shell--scroll-nested',
|
||||
'app-shell--scroll-locked',
|
||||
'app-shell--scrollbar-hidden',
|
||||
];
|
||||
|
||||
const MANAGED_SCREEN_CLASSES = [
|
||||
'no-app-chrome',
|
||||
'preauth-flow',
|
||||
'filled-action-buttons',
|
||||
'settings-bordered-actions',
|
||||
];
|
||||
|
||||
const DEFAULT_SHELL_MODE = Object.freeze({
|
||||
topFade: true,
|
||||
bottomFade: false,
|
||||
bottomFadeAnchor: 'composer',
|
||||
fadeProfile: 'standard',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: false,
|
||||
scrollContainer: 'screen',
|
||||
scrollbar: 'auto',
|
||||
});
|
||||
|
||||
function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||
const source = mode && typeof mode === 'object' ? mode : {};
|
||||
const normalized = { ...DEFAULT_SHELL_MODE, ...source };
|
||||
if (!showAppChrome) {
|
||||
normalized.topFade = false;
|
||||
normalized.bottomFade = false;
|
||||
normalized.contentUnderTopbar = false;
|
||||
normalized.contentUnderBottom = false;
|
||||
}
|
||||
normalized.bottomFadeAnchor = normalized.bottomFadeAnchor === 'toolbar' ? 'toolbar' : 'composer';
|
||||
normalized.fadeProfile = normalized.fadeProfile === 'edge' ? 'edge' : 'standard';
|
||||
normalized.scrollContainer = ['screen', 'nested', 'locked'].includes(normalized.scrollContainer)
|
||||
? normalized.scrollContainer
|
||||
: 'screen';
|
||||
normalized.scrollbar = normalized.scrollbar === 'hidden' ? 'hidden' : 'auto';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applyShellMode(mode, showAppChrome = true) {
|
||||
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
|
||||
const normalized = normalizeShellMode(mode, showAppChrome);
|
||||
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-toolbar', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'toolbar');
|
||||
appShellEl.classList.toggle('app-shell--fade-edge', normalized.fadeProfile === 'edge');
|
||||
appShellEl.classList.toggle('app-shell--content-under-topbar', Boolean(normalized.contentUnderTopbar));
|
||||
appShellEl.classList.toggle('app-shell--content-under-bottom', Boolean(normalized.contentUnderBottom));
|
||||
appShellEl.classList.toggle('app-shell--scroll-nested', normalized.scrollContainer === 'nested');
|
||||
appShellEl.classList.toggle('app-shell--scroll-locked', normalized.scrollContainer === 'locked');
|
||||
appShellEl.classList.toggle('app-shell--scrollbar-hidden', normalized.scrollbar === 'hidden');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resetShellMode() {
|
||||
appShellEl?.classList.remove(...MANAGED_SHELL_CLASSES);
|
||||
}
|
||||
|
||||
function resetManagedScreenClasses() {
|
||||
screenEl?.classList.remove(...MANAGED_SCREEN_CLASSES);
|
||||
}
|
||||
|
||||
function clearSlot(slotEl, cssVarName, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
slotEl.hidden = true;
|
||||
if (presenceClass) appShellEl?.classList.remove(presenceClass);
|
||||
setShellMetricVar(cssVarName, 0);
|
||||
}
|
||||
|
||||
function mountSlot(slotEl, cssVarName, node) {
|
||||
function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
|
||||
if (!slotEl) return;
|
||||
slotEl.innerHTML = '';
|
||||
if (node instanceof Node) {
|
||||
@@ -405,59 +489,89 @@ function mountSlot(slotEl, cssVarName, node) {
|
||||
} else {
|
||||
slotEl.hidden = true;
|
||||
}
|
||||
if (presenceClass) appShellEl?.classList.toggle(presenceClass, !slotEl.hidden);
|
||||
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
|
||||
}
|
||||
|
||||
function createChromeController(showAppChrome) {
|
||||
function createChromeController(showAppChrome, initialShellMode = {}) {
|
||||
let topbarNode = null;
|
||||
let composerNode = null;
|
||||
|
||||
const cleanupOwnedNode = (node) => {
|
||||
if (node && typeof node.cleanup === 'function') node.cleanup();
|
||||
};
|
||||
let shellMode = normalizeShellMode(initialShellMode, showAppChrome);
|
||||
let disposed = false;
|
||||
|
||||
const apply = () => {
|
||||
if (disposed) return;
|
||||
applyShellMode(shellMode, showAppChrome);
|
||||
if (!showAppChrome) {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
return;
|
||||
}
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode);
|
||||
mountSlot(composerEl, '--composer-height', composerNode);
|
||||
mountSlot(topbarEl, '--topbar-height', topbarNode, 'app-shell--has-topbar');
|
||||
mountSlot(composerEl, '--composer-height', composerNode, 'app-shell--has-composer');
|
||||
topbarHeightObserver?.sync?.();
|
||||
composerHeightObserver?.sync?.();
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
return {
|
||||
setTopbar(node = null) {
|
||||
topbarNode = node instanceof Node ? node : null;
|
||||
const nextTopbar = node instanceof Node ? node : null;
|
||||
if (topbarNode && topbarNode !== nextTopbar) cleanupOwnedNode(topbarNode);
|
||||
topbarNode = nextTopbar;
|
||||
apply();
|
||||
},
|
||||
setComposer(node = null) {
|
||||
composerNode = node instanceof Node ? node : null;
|
||||
const nextComposer = node instanceof Node ? node : null;
|
||||
if (composerNode && composerNode !== nextComposer) cleanupOwnedNode(composerNode);
|
||||
composerNode = nextComposer;
|
||||
apply();
|
||||
},
|
||||
setShellMode(nextMode = {}) {
|
||||
shellMode = normalizeShellMode({ ...shellMode, ...(nextMode || {}) }, showAppChrome);
|
||||
apply();
|
||||
},
|
||||
clear() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
apply();
|
||||
},
|
||||
suspend() {
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
},
|
||||
resume() {
|
||||
apply();
|
||||
},
|
||||
dispose() {
|
||||
cleanupOwnedNode(topbarNode);
|
||||
cleanupOwnedNode(composerNode);
|
||||
topbarNode = null;
|
||||
composerNode = null;
|
||||
disposed = true;
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
resetShellMode();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearKeepAliveEntries() {
|
||||
currentCleanup = null;
|
||||
currentChromeCleanup = null;
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockHiddenDmAudio() {
|
||||
@@ -741,6 +855,77 @@ function consumeCallPushActionFromUrlIfAny() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function pushTargetLogin(payload = {}) {
|
||||
return String(payload?.toLogin || '').trim();
|
||||
}
|
||||
|
||||
function pushTargetPath(payload = {}) {
|
||||
const kind = String(payload?.kind || '').trim();
|
||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||
if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`;
|
||||
return '/profile';
|
||||
}
|
||||
|
||||
function savedProfileExists(login) {
|
||||
const normalized = String(login || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) {
|
||||
const targetLogin = pushTargetLogin(payload);
|
||||
const currentLogin = String(state.session.login || '').trim();
|
||||
if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true;
|
||||
if (!savedProfileExists(targetLogin)) {
|
||||
showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = String(payload?.kind || '').trim();
|
||||
const question = kind === 'incoming_call'
|
||||
? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?`
|
||||
: `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`;
|
||||
if (!window.confirm(question)) return false;
|
||||
|
||||
try {
|
||||
await switchToSavedProfile(targetLogin);
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
savePendingCallPushAction(action, payload);
|
||||
window.location.assign(pushTargetPath(payload));
|
||||
} else {
|
||||
window.location.assign(pushTargetPath(payload));
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationClick(payload = {}) {
|
||||
const canOpen = await ensurePushTargetProfile(payload);
|
||||
if (!canOpen) return;
|
||||
const path = pushTargetPath(payload);
|
||||
navigate(path.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function consumeNotificationOpenFromUrlIfAny() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const rawPayload = String(params.get('pushOpenPayload') || '');
|
||||
if (!rawPayload) return null;
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {}
|
||||
params.delete('pushOpenPayload');
|
||||
const nextQuery = params.toString();
|
||||
window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingCallPushActionIfPossible() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
const pending = loadPendingCallPushAction();
|
||||
@@ -1163,14 +1348,15 @@ function renderPageFailureFallback(pageId, error) {
|
||||
wrap.append(card);
|
||||
screenEl.append(wrap);
|
||||
|
||||
resetManagedScreenClasses();
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
if (typeof currentChromeCleanup === 'function') {
|
||||
currentChromeCleanup();
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
clearSlot(topbarEl, '--topbar-height');
|
||||
clearSlot(composerEl, '--composer-height');
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
|
||||
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
}
|
||||
@@ -1193,7 +1379,8 @@ function renderApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
|
||||
const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles';
|
||||
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) {
|
||||
navigate('messages-list');
|
||||
return;
|
||||
}
|
||||
@@ -1210,10 +1397,23 @@ function renderApp() {
|
||||
currentChromeCleanup = null;
|
||||
}
|
||||
|
||||
// Сначала полностью очищаем page-owned UI предыдущего маршрута, затем
|
||||
// применяем новый shell mode и только после этого монтируем следующий экран.
|
||||
screenEl.innerHTML = '';
|
||||
resetManagedScreenClasses();
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
try {
|
||||
screenEl.innerHTML = '';
|
||||
const chrome = createChromeController(showAppChrome);
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
const screen = page.render({ route, navigate, chrome });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
@@ -1227,16 +1427,6 @@ function renderApp() {
|
||||
scrollToBottomControl?.cleanup();
|
||||
};
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
@@ -1251,9 +1441,9 @@ function refreshToolbarOnly() {
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
refreshConnectionUi();
|
||||
@@ -1282,6 +1472,11 @@ async function tryAutoLogin() {
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
if (result?.nextProfile) {
|
||||
window.location.assign('/profile');
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
@@ -1334,6 +1529,7 @@ async function ensureSessionRuntimeStarted() {
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
if (state.session.isLocalDemo) {
|
||||
@@ -1369,12 +1565,20 @@ async function init() {
|
||||
const action = String(data.action || '').trim().toLowerCase();
|
||||
const payload = data.payload || {};
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||
savePendingCallPushAction(action, payload);
|
||||
void processPendingCallPushActionIfPossible();
|
||||
void (async () => {
|
||||
const canHandle = await ensurePushTargetProfile(payload, { action });
|
||||
if (!canHandle) return;
|
||||
if (!isCallPushTargetForCurrentSession(payload)) return;
|
||||
savePendingCallPushAction(action, payload);
|
||||
await processPendingCallPushActionIfPossible();
|
||||
})();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'SHINE_NOTIFICATION_CLICK') {
|
||||
void handleNotificationClick(data.payload || {});
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||
|
||||
const payload = data.payload || {};
|
||||
@@ -1407,7 +1611,8 @@ async function init() {
|
||||
}
|
||||
|
||||
authService.onEvent('SessionRevoked', async () => {
|
||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
||||
const result = await closeSavedProfile(state.session.login);
|
||||
window.location.assign(result?.nextProfile ? '/profile' : '/start');
|
||||
});
|
||||
|
||||
authService.onEvent('ForceUiReload', async (evt) => {
|
||||
@@ -1710,6 +1915,9 @@ async function init() {
|
||||
void (async () => {
|
||||
try {
|
||||
await tryAutoLogin();
|
||||
if (initialNotificationOpenPayload) {
|
||||
await handleNotificationClick(initialNotificationOpenPayload);
|
||||
}
|
||||
await hydrateMessagesFromStore();
|
||||
if (!state.session.isLocalDemo) {
|
||||
startConnectionMonitor();
|
||||
|
||||
@@ -1,88 +1,157 @@
|
||||
let activeDropdown = null;
|
||||
|
||||
function normalizeItems(items) {
|
||||
const value = typeof items === 'function' ? items() : items;
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function resolvePlacement({ placement, align }) {
|
||||
if (placement === 'bottom-start' || placement === 'top-start') return 'left';
|
||||
if (placement === 'bottom-end' || placement === 'top-end') return 'right';
|
||||
return align === 'left' ? 'left' : 'right';
|
||||
}
|
||||
|
||||
export function createDropdownMenu({
|
||||
anchorEl,
|
||||
items = [],
|
||||
renderContent = null,
|
||||
className = '',
|
||||
minWidth = 210,
|
||||
offset = 7,
|
||||
align = 'right',
|
||||
placement = 'bottom-end',
|
||||
align = null,
|
||||
leftShift = 0,
|
||||
transparent = false,
|
||||
dimBackground = true,
|
||||
keepAnchorPressed = true,
|
||||
onOpen = null,
|
||||
onClose = null,
|
||||
} = {}) {
|
||||
let portal = null;
|
||||
let menuEl = null;
|
||||
let destroyed = false;
|
||||
|
||||
const close = () => {
|
||||
const setAnchorOpen = (isOpen) => {
|
||||
if (!anchorEl) return;
|
||||
anchorEl.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true';
|
||||
else delete anchorEl.dataset.open;
|
||||
};
|
||||
|
||||
const close = ({ focusAnchor = false } = {}) => {
|
||||
if (!portal) return;
|
||||
portal.remove();
|
||||
portal = null;
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
menuEl = null;
|
||||
if (activeDropdown === api) activeDropdown = null;
|
||||
setAnchorOpen(false);
|
||||
onClose?.();
|
||||
if (focusAnchor) anchorEl?.focus?.();
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!portal || !anchorEl) return;
|
||||
if (!portal || !menuEl || !anchorEl) return;
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = portal.offsetWidth || minWidth;
|
||||
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const menuWidth = menuEl.offsetWidth || minWidth;
|
||||
const side = resolvePlacement({ placement, align });
|
||||
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const desiredLeft = baseLeft - Number(leftShift || 0);
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||
let top = rect.bottom + offset;
|
||||
const menuHeight = portal.offsetHeight || 180;
|
||||
if (top + menuHeight > window.innerHeight - margin) {
|
||||
|
||||
const menuHeight = menuEl.offsetHeight || 180;
|
||||
const prefersTop = String(placement || '').startsWith('top-');
|
||||
let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset;
|
||||
if (!prefersTop && top + menuHeight > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||
} else if (prefersTop && top < margin) {
|
||||
top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset);
|
||||
}
|
||||
portal.style.left = `${Math.round(left)}px`;
|
||||
portal.style.top = `${Math.round(top)}px`;
|
||||
|
||||
menuEl.style.left = `${Math.round(left)}px`;
|
||||
menuEl.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!anchorEl || portal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.style.minWidth = `${minWidth}px`;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.divider) {
|
||||
const appendItems = () => {
|
||||
normalizeItems(items).forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
menu.append(divider);
|
||||
divider.className = 'dropdown-menu__divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
menuEl.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
if (item?.iconHtml) {
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`;
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.disabled = Boolean(item.disabled);
|
||||
|
||||
if (item.iconHtml) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shared-dropdown-menu__icon';
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.innerHTML = item.iconHtml;
|
||||
btn.append(icon);
|
||||
} else if (item?.iconSrc) {
|
||||
button.append(icon);
|
||||
} else if (item.iconSrc) {
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.src = item.iconSrc;
|
||||
icon.alt = '';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon);
|
||||
button.append(icon);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(item?.label || '');
|
||||
btn.append(label);
|
||||
btn.addEventListener('click', (event) => {
|
||||
label.textContent = String(item.label || '');
|
||||
button.append(label);
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
item?.action?.();
|
||||
await item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
menuEl.append(button);
|
||||
});
|
||||
};
|
||||
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
document.body.append(menu);
|
||||
portal = menu;
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
const open = () => {
|
||||
if (destroyed || !anchorEl || portal) return;
|
||||
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
|
||||
|
||||
portal = document.createElement('div');
|
||||
portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`;
|
||||
|
||||
if (dimBackground) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dropdown-backdrop';
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === backdrop) close();
|
||||
});
|
||||
portal.append(backdrop);
|
||||
}
|
||||
|
||||
menuEl = document.createElement('div');
|
||||
menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`;
|
||||
menuEl.setAttribute('role', 'menu');
|
||||
menuEl.style.minWidth = `${minWidth}px`;
|
||||
|
||||
if (typeof renderContent === 'function') {
|
||||
const content = renderContent({ close, menuEl });
|
||||
if (content instanceof Node) menuEl.append(content);
|
||||
} else {
|
||||
appendItems();
|
||||
}
|
||||
|
||||
menuEl.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
menuEl.addEventListener('click', (event) => event.stopPropagation());
|
||||
portal.append(menuEl);
|
||||
document.body.append(portal);
|
||||
activeDropdown = api;
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
};
|
||||
@@ -97,43 +166,49 @@ export function createDropdownMenu({
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
};
|
||||
const onOutsideClick = (event) => {
|
||||
const onOutsidePointerDown = (event) => {
|
||||
if (!portal) return;
|
||||
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!portal || event?.detail?.owner === anchorEl) return;
|
||||
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close();
|
||||
anchorEl?.focus();
|
||||
close({ focusAnchor: true });
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
setAnchorOpen(false);
|
||||
anchorEl?.addEventListener('click', onAnchorClick);
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', position, { passive: true });
|
||||
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||
window.addEventListener('popstate', onNavigation);
|
||||
window.addEventListener('hashchange', onNavigation);
|
||||
window.addEventListener('resize', onViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
const api = {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
position,
|
||||
get isOpen() {
|
||||
return Boolean(portal);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
close();
|
||||
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', position);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
window.removeEventListener('popstate', onNavigation);
|
||||
window.removeEventListener('hashchange', onNavigation);
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('scroll', onViewportChange, true);
|
||||
setAnchorOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'header-left-label';
|
||||
label.textContent = leftLabel;
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
rightActions.forEach((action) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) btn.title = action.title;
|
||||
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.iconNode instanceof Node) {
|
||||
btn.append(action.iconNode);
|
||||
} else {
|
||||
btn.textContent = action.label;
|
||||
}
|
||||
btn.addEventListener('click', action.onClick);
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state } from '../state.js';
|
||||
import { state, authService } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
@@ -20,16 +20,68 @@ function iconHtml(item) {
|
||||
: `<span>${item.icon}</span>`;
|
||||
}
|
||||
|
||||
function getTotalUnreadMessages() {
|
||||
const chats = Object.values(state.chats || {});
|
||||
let total = 0;
|
||||
chats.forEach((messages) => {
|
||||
if (!Array.isArray(messages)) return;
|
||||
messages.forEach((msg) => {
|
||||
if (msg?.from === 'in' && msg?.unread) total += 1;
|
||||
});
|
||||
function normalizeCounters(payload = {}) {
|
||||
const notifications = payload?.notifications || {};
|
||||
const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? (
|
||||
Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0)
|
||||
)) || 0);
|
||||
return {
|
||||
dmUnreadCount: Math.max(0, Number(payload?.dmUnreadCount || 0) || 0),
|
||||
channelsUnreadCount: Math.max(0, Number(payload?.channelsUnreadCount || 0) || 0),
|
||||
notificationsUnreadCount: notificationTotal,
|
||||
notifications: {
|
||||
replies: Math.max(0, Number(notifications.replies ?? payload?.notificationRepliesUnreadCount ?? 0) || 0),
|
||||
connections: Math.max(0, Number(notifications.connections ?? payload?.notificationConnectionsUnreadCount ?? 0) || 0),
|
||||
events: Math.max(0, Number(notifications.events ?? payload?.notificationEventsUnreadCount ?? 0) || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setCounterState(payload = {}) {
|
||||
state.userCounters = normalizeCounters(payload);
|
||||
state.notificationUnreadTotal = state.userCounters.notificationsUnreadCount;
|
||||
}
|
||||
|
||||
function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.toolbar-unread-badge');
|
||||
if (count <= 0) { badge?.remove(); return; }
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||
btn.append(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||
}
|
||||
|
||||
function applyCountersToMountedToolbars() {
|
||||
const c = normalizeCounters(state.userCounters);
|
||||
document.querySelectorAll('.toolbar').forEach((toolbar) => {
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="messages-list"]'), c.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="channels-list"]'), c.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="notifications-view"]'), c.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
let countersPushBound = false;
|
||||
function ensureCountersPushBound() {
|
||||
if (countersPushBound) return;
|
||||
countersPushBound = true;
|
||||
authService.onEvent('UserCountersChanged', (event) => {
|
||||
setCounterState(event?.payload || {});
|
||||
applyCountersToMountedToolbars();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUserCounters() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
try {
|
||||
setCounterState(await authService.getUserCounters());
|
||||
applyCountersToMountedToolbars();
|
||||
} catch {
|
||||
// Keep the last known counters; realtime push or the next refresh can recover.
|
||||
}
|
||||
}
|
||||
|
||||
function navigateWithGuestRules(pageId, navigate) {
|
||||
@@ -65,13 +117,16 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const root = document.createElement('nav');
|
||||
root.className = 'toolbar';
|
||||
const active = resolveToolbarActive(currentPageId);
|
||||
const unreadTotal = getTotalUnreadMessages();
|
||||
ensureCountersPushBound();
|
||||
const counters = normalizeCounters(state.userCounters);
|
||||
|
||||
ITEMS.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
const isProfile = item.pageId === 'profile-view';
|
||||
const isMessages = item.pageId === 'messages-list';
|
||||
const isNetwork = item.pageId === 'network-view';
|
||||
const isNotifications = item.pageId === 'notifications-view';
|
||||
btn.dataset.toolbarPage = item.pageId;
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||
if (isProfile) {
|
||||
btn.innerHTML = `
|
||||
@@ -90,13 +145,9 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
}
|
||||
if (isMessages && unreadTotal > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'toolbar-unread-badge';
|
||||
badge.textContent = unreadTotal > 99 ? '99+' : String(unreadTotal);
|
||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||
btn.append(badge);
|
||||
}
|
||||
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
if (item.pageId === 'channels-list') {
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
@@ -105,5 +156,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
root.append(btn);
|
||||
});
|
||||
|
||||
if (state.session.isAuthorized) void refreshUserCounters();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
if (!target || !(node instanceof Node)) return;
|
||||
target.append(node);
|
||||
}
|
||||
|
||||
function createActionButton(action = {}, cleanupFns) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__action${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) button.title = action.title;
|
||||
if (action.ariaLabel) button.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.id) button.dataset.action = action.id;
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
|
||||
if (action.menu) {
|
||||
const menu = createDropdownMenu({
|
||||
anchorEl: button,
|
||||
transparent: true,
|
||||
dimBackground: true,
|
||||
keepAnchorPressed: true,
|
||||
...(typeof action.menu === 'object' ? action.menu : {}),
|
||||
});
|
||||
cleanupFns.add(() => menu.destroy());
|
||||
} else if (typeof action.onClick === 'function') {
|
||||
button.addEventListener('click', action.onClick);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createTopBar({
|
||||
title = '',
|
||||
className = '',
|
||||
left = null,
|
||||
center = null,
|
||||
back = null,
|
||||
leftLabel = '',
|
||||
actions = [],
|
||||
} = {}) {
|
||||
const cleanupFns = new Set();
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = `topbar${className ? ` ${className}` : ''}`;
|
||||
|
||||
const leftSlot = document.createElement('div');
|
||||
leftSlot.className = 'topbar__left';
|
||||
|
||||
const backAction = back;
|
||||
if (backAction?.visible !== false && typeof backAction?.onClick === 'function') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
leftSlot.append(button);
|
||||
}
|
||||
|
||||
appendNode(leftSlot, left);
|
||||
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'topbar__left-label';
|
||||
label.textContent = leftLabel;
|
||||
leftSlot.append(label);
|
||||
}
|
||||
|
||||
const centerSlot = document.createElement('div');
|
||||
centerSlot.className = 'topbar__center';
|
||||
const resolvedCenter = center;
|
||||
if (resolvedCenter instanceof Node) {
|
||||
centerSlot.append(resolvedCenter);
|
||||
} else {
|
||||
const heading = document.createElement('h1');
|
||||
heading.className = 'topbar__title';
|
||||
heading.textContent = String(title || '');
|
||||
centerSlot.append(heading);
|
||||
}
|
||||
|
||||
const rightSlot = document.createElement('div');
|
||||
rightSlot.className = 'topbar__right';
|
||||
const normalizedActions = actions;
|
||||
normalizedActions.forEach((action) => {
|
||||
rightSlot.append(createActionButton(action, cleanupFns));
|
||||
});
|
||||
|
||||
topbar.append(leftSlot, centerSlot, rightSlot);
|
||||
|
||||
topbar.addCleanup = (cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanupFns.add(cleanup);
|
||||
return cleanup;
|
||||
};
|
||||
topbar.cleanup = () => {
|
||||
for (const cleanup of cleanupFns) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
console.warn('[TopBar] cleanup failed', error);
|
||||
}
|
||||
}
|
||||
cleanupFns.clear();
|
||||
};
|
||||
|
||||
return topbar;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
@@ -259,7 +259,7 @@ function createPasswordModal() {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
@@ -427,7 +427,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = items.map((item) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
@${escapeHtml(item.login)}
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
|
||||
</button>`
|
||||
@@ -633,11 +633,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Сервер доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
screen.append(
|
||||
introCard,
|
||||
listCard,
|
||||
addCard,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
@@ -78,16 +78,14 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Создание канала',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -97,7 +95,7 @@ export function render({ navigate }) {
|
||||
<div class="channel-create-avatar-side">
|
||||
<div class="channel-create-avatar-status-row">
|
||||
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
|
||||
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
<button type="button" class="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
|
||||
</div>
|
||||
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
@@ -47,16 +47,14 @@ function createDebounced(fn, delayMs = 240) {
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}),
|
||||
);
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
@@ -119,7 +117,7 @@ export function render({ navigate }) {
|
||||
rows.slice(0, 8).forEach((login) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = String(login);
|
||||
btn.addEventListener('click', () => {
|
||||
selectedCanonicalLogin = String(login);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
|
||||
@@ -11,16 +11,14 @@ function formatTime(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
|
||||
|
||||
function closeUploadsMenu(controls) {
|
||||
const menu = controls?.querySelector?.('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
function setStatusBadge(node, status, label) {
|
||||
if (!node) return;
|
||||
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
|
||||
@@ -64,25 +60,10 @@ function renderTile(item, index) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack arweave-uploads-screen';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'arweave-uploads-toolbar';
|
||||
controls.innerHTML = `
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
@@ -91,7 +72,6 @@ export function render({ navigate }) {
|
||||
|
||||
const uploadFile = async () => {
|
||||
statusLine.textContent = '';
|
||||
closeUploadsMenu(controls);
|
||||
try {
|
||||
await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
@@ -134,42 +114,50 @@ export function render({ navigate }) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
label: '+',
|
||||
title: 'Добавить файл',
|
||||
ariaLabel: 'Добавить файл',
|
||||
className: 'arweave-uploads-add',
|
||||
onClick: () => { void uploadFile(); },
|
||||
},
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню',
|
||||
ariaLabel: 'Меню загрузок',
|
||||
className: 'arweave-uploads-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Добавить файл', action: () => uploadFile() },
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
action: () => {
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Справка',
|
||||
action: () => window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
|
||||
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = !menu.hidden;
|
||||
});
|
||||
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
});
|
||||
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
});
|
||||
screen.addEventListener('click', (event) => {
|
||||
if (controls.contains(event.target)) return;
|
||||
closeUploadsMenu(controls);
|
||||
});
|
||||
|
||||
screen.append(controls, statusLine, list);
|
||||
chrome?.setTopbar(topbar);
|
||||
screen.append(statusLine, list);
|
||||
renderHistory();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
@@ -63,9 +63,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = renderHeader({
|
||||
const topbar = createTopBar({
|
||||
title: 'О канале',
|
||||
leftAction: {
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (window.history.length > 1) {
|
||||
@@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'meta-muted screen-footer';
|
||||
footer.textContent = 'О канале (channel-about-view)';
|
||||
|
||||
screen.append(card, footer);
|
||||
screen.append(card);
|
||||
|
||||
const renderContent = (channel) => {
|
||||
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -437,7 +437,7 @@ function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, l
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
@@ -471,6 +471,7 @@ function openBlockchainDetailsModal(details) {
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
if (!isActive()) return;
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
|
||||
@@ -495,7 +496,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
@@ -508,7 +509,7 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
@@ -569,9 +570,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
@@ -589,10 +592,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
@@ -602,7 +606,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
@@ -668,8 +672,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
@@ -716,7 +722,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
@@ -749,16 +755,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
@@ -775,6 +785,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const primaryLikes = Number(node?.primaryLikesCount || 0);
|
||||
const shiningLikes = Number(node?.shiningLikesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const ratings = Number(node?.ratingsCount || 0);
|
||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||
@@ -796,7 +808,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -819,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'message-edited-marker';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
editedMarker.title = 'Открыть историю редактирования';
|
||||
editedMarker.addEventListener('click', (event) => {
|
||||
@@ -842,7 +854,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (typeMeta) {
|
||||
const typeButton = document.createElement('button');
|
||||
typeButton.type = 'button';
|
||||
typeButton.className = 'channel-message-type-button';
|
||||
typeButton.className = 'ui-button channel-message-type-button';
|
||||
typeButton.textContent = typeMeta.label;
|
||||
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
|
||||
typeButton.addEventListener('click', (event) => {
|
||||
@@ -863,7 +875,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.className = 'ui-button deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${author}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
@@ -913,12 +925,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item thread-like-btn';
|
||||
likeButton.className = 'ui-button channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
@@ -947,7 +959,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item thread-reply-btn';
|
||||
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
@@ -960,30 +972,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
isActive: handlers.isActive,
|
||||
});
|
||||
});
|
||||
const ratingButton = document.createElement('button');
|
||||
ratingButton.type = 'button';
|
||||
ratingButton.className = 'channel-action-item thread-rating-btn';
|
||||
ratingButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||
<span class="channel-action-label">Оценка</span>
|
||||
<span class="channel-action-counter">${ratings}</span>
|
||||
`;
|
||||
setActionTitle(ratingButton, 'Оценка');
|
||||
ratingButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
mode: 'rating',
|
||||
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'channel-action-item thread-share-btn';
|
||||
shareButton.className = 'ui-button channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
@@ -997,11 +994,11 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
originalButton.className = 'channel-action-item';
|
||||
originalButton.className = 'ui-button channel-action-item';
|
||||
originalButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
@@ -1023,7 +1020,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.className = 'ui-button channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
@@ -1039,13 +1036,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
raw: node,
|
||||
localNumber,
|
||||
msgSubType,
|
||||
}));
|
||||
}), { isActive: handlers.isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.className = 'ui-button channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
@@ -1100,11 +1097,12 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
|
||||
const target = pendingThreadScroll.get(routeKey);
|
||||
if (!target) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (!shouldContinue()) return;
|
||||
if (target === '__LAST_REPLY__') {
|
||||
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
@@ -1122,7 +1120,7 @@ function applyPendingScroll(screen, routeKey) {
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(doScroll, 20);
|
||||
return window.setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
@@ -1134,15 +1132,18 @@ function renderSkeleton(screen) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
let selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
let activeResolvedChannelLabel = channelDisplayName;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
let refresh = () => {};
|
||||
const refreshTimers = new Set();
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
@@ -1150,10 +1151,10 @@ export function render({ navigate, route, chrome }) {
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = renderHeader({
|
||||
centerNode: threadHeaderButton,
|
||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
rightActions: [
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
@@ -1170,19 +1171,12 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
try {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.cleanup?.();
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
}
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (disposed) return;
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
@@ -1205,6 +1199,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const handlers = {
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
@@ -1222,12 +1217,14 @@ export function render({ navigate, route, chrome }) {
|
||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, nextReaction);
|
||||
softHaptic(10);
|
||||
rerender();
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
setMessageReactionState(target, previousReaction || 'unliked');
|
||||
rerender();
|
||||
void refresh();
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
@@ -1236,24 +1233,27 @@ export function render({ navigate, route, chrome }) {
|
||||
onReply: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Ответ отправлен');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRating: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||
ensureActive();
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Оценка отправлена');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
if (disposed) return;
|
||||
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
|
||||
.map((row) => {
|
||||
const selectorRow = {
|
||||
@@ -1278,6 +1278,7 @@ export function render({ navigate, route, chrome }) {
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
@@ -1286,6 +1287,7 @@ export function render({ navigate, route, chrome }) {
|
||||
message: target,
|
||||
text,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
showStatus('');
|
||||
@@ -1301,6 +1303,7 @@ export function render({ navigate, route, chrome }) {
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
if (result === 'copied') showToast('Ссылка скопирована');
|
||||
if (result === 'shared') showToast('Ссылка передана');
|
||||
if (result === 'copied' || result === 'shared') softHaptic(10);
|
||||
@@ -1334,33 +1337,69 @@ export function render({ navigate, route, chrome }) {
|
||||
isChannelPost: meta?.isChannelPost === true,
|
||||
channel: selector?.channel || null,
|
||||
});
|
||||
ensureActive();
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
showStatus('');
|
||||
rerender();
|
||||
void refresh();
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
const clearContent = () => {
|
||||
for (const timerId of refreshTimers) window.clearTimeout(timerId);
|
||||
refreshTimers.clear();
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
if (child !== statusBox) child.remove();
|
||||
});
|
||||
};
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const clearOwnedModal = () => {
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
if (!modalRoot) return;
|
||||
if (modalRoot.querySelector([
|
||||
'#thread-blockchain-details-modal',
|
||||
'#thread-edit-modal',
|
||||
'#thread-history-modal',
|
||||
'#thread-reply-modal',
|
||||
'#thread-repost-modal',
|
||||
].join(','))) {
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
const trackTimer = (timerId) => {
|
||||
if (timerId) refreshTimers.add(timerId);
|
||||
return timerId;
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let resolvedMessage = selector.message;
|
||||
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
|
||||
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const allRows = [
|
||||
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
|
||||
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
||||
@@ -1383,6 +1422,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(ownerRaw);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
if (ownerBch) {
|
||||
channel = allRows.find((item) => (
|
||||
@@ -1397,6 +1437,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!channel && ownerLoginFromBch) {
|
||||
try {
|
||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||
channel = ownerRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
||||
@@ -1426,6 +1467,7 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
|
||||
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
@@ -1451,6 +1493,7 @@ export function render({ navigate, route, chrome }) {
|
||||
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
}
|
||||
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
@@ -1467,10 +1510,10 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
seq += 1;
|
||||
return seq;
|
||||
localSeq += 1;
|
||||
return localSeq;
|
||||
};
|
||||
|
||||
let ancestorsWrap = null;
|
||||
@@ -1520,25 +1563,33 @@ export function render({ navigate, route, chrome }) {
|
||||
if (focusWrap) screen.append(focusWrap);
|
||||
screen.append(descendantsWrap);
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
if (!hasPendingScroll && focusWrap) {
|
||||
setTimeout(() => {
|
||||
trackTimer(window.setTimeout(() => {
|
||||
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
|
||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}, 20);
|
||||
}, 20));
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
screen.append(failed);
|
||||
}
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
clearOwnedModal();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
+548
-174
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,12 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const DIARY_CHANNEL_NAME = 'diary';
|
||||
@@ -52,6 +51,18 @@ function cleanChannelMessagePreview(text) {
|
||||
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
}
|
||||
|
||||
// Keep the channel-list preview tolerant to small API naming changes. The current
|
||||
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
|
||||
// so the third line (last message + time) does not silently disappear.
|
||||
function resolveChannelLastMessage(summary) {
|
||||
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
|
||||
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
|
||||
return {
|
||||
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
|
||||
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
@@ -246,7 +257,7 @@ function renderSuggestions(container, values, onPick) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -516,7 +527,7 @@ function openChannelFinderModal({ navigate }) {
|
||||
values.forEach((value) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-search-item';
|
||||
btn.className = 'ui-button channel-search-item';
|
||||
btn.textContent = value.label;
|
||||
btn.addEventListener('click', () => onPick(value));
|
||||
container.append(btn);
|
||||
@@ -718,7 +729,8 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const title = displayTitle || channelName;
|
||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||
const technicalLabel = `@${ownerLogin}/${channelName}`;
|
||||
const lastMessage = resolveChannelLastMessage(summary);
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
@@ -737,10 +749,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
channelDescription,
|
||||
channelTypeCode,
|
||||
channelTypeVersion,
|
||||
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
|
||||
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||
isOwnChannel: isOwn,
|
||||
isSubscribed: !isOwn,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
@@ -932,263 +944,25 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function closeChannelMenu(listState, clearOpenMenuId = true) {
|
||||
if (typeof listState.menuCleanup === 'function') {
|
||||
listState.menuCleanup();
|
||||
}
|
||||
listState.menuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
if (clearOpenMenuId) {
|
||||
listState.openMenuId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeTopChannelsMenu(listState) {
|
||||
if (typeof listState.topMenuCleanup === 'function') {
|
||||
listState.topMenuCleanup();
|
||||
}
|
||||
listState.topMenuCleanup = null;
|
||||
const root = document.getElementById('modal-root');
|
||||
if (root) {
|
||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl,
|
||||
navigate,
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 250;
|
||||
const titleAnchor = document.querySelector('.channels-filter-title');
|
||||
const titleRect = titleAnchor?.getBoundingClientRect?.();
|
||||
let top = (titleRect?.bottom || rect.bottom) + 7;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = TOP_MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
divider.style.margin = '6px 0';
|
||||
menu.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-menu-item';
|
||||
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
closeTopChannelsMenu(listState);
|
||||
item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
});
|
||||
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (event?.detail?.owner === anchorEl) return;
|
||||
closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
listState.topMenuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
};
|
||||
}
|
||||
|
||||
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||||
closeChannelMenu(listState, false);
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
|
||||
let left = rect.right - menuWidth;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 210;
|
||||
let top = rect.bottom + 8;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = MENU_OVERLAY_ID;
|
||||
overlay.className = 'channels-menu-overlay';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(top)}px`;
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const canToggleSubscription = !channel.isOwnChannel;
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||
|
||||
const actionLabel = document.createElement('span');
|
||||
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||
|
||||
if (canToggleSubscription) {
|
||||
actionLabel.textContent = channel.pending
|
||||
? 'Выполняется...'
|
||||
: channel.isSubscribed
|
||||
? 'Отписаться'
|
||||
: 'Подписаться';
|
||||
actionBtn.disabled = !!channel.pending;
|
||||
|
||||
actionBtn.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
if (channel.pending) return;
|
||||
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
channel.pending = true;
|
||||
actionBtn.disabled = true;
|
||||
actionLabel.textContent = 'Выполняется...';
|
||||
|
||||
const nextSubscribed = !channel.isSubscribed;
|
||||
try {
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: channel.ownerBlockchainName,
|
||||
targetBlockNumber: channel.channelRootBlockNumber,
|
||||
targetBlockHashHex: channel.channelRootBlockHash,
|
||||
unfollow: !nextSubscribed,
|
||||
});
|
||||
|
||||
channel.isSubscribed = nextSubscribed;
|
||||
channel.pending = false;
|
||||
softHaptic(15);
|
||||
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
|
||||
closeChannelMenu(listState);
|
||||
await refreshFeed();
|
||||
} catch (error) {
|
||||
channel.pending = false;
|
||||
actionBtn.disabled = false;
|
||||
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||
rerenderList();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionLabel.textContent = 'Собственный канал';
|
||||
actionBtn.disabled = true;
|
||||
}
|
||||
|
||||
const toggleWrap = document.createElement('div');
|
||||
toggleWrap.className = 'channel-menu-toggle';
|
||||
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.className = 'channel-menu-toggle-label';
|
||||
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
|
||||
toggleBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(toggleBtn);
|
||||
|
||||
channel.notificationsEnabled = !channel.notificationsEnabled;
|
||||
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
|
||||
listState.notificationsState = next;
|
||||
writeChannelNotificationsState(next);
|
||||
|
||||
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
|
||||
softHaptic(10);
|
||||
});
|
||||
|
||||
toggleWrap.append(toggleLabel, toggleBtn);
|
||||
menu.append(actionBtn, toggleWrap);
|
||||
overlay.append(menu);
|
||||
root.append(overlay);
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowResize = () => {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
listState.menuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMain(channel) {
|
||||
const main = document.createElement('div');
|
||||
main.className = 'channel-row-main';
|
||||
|
||||
const titleLine = document.createElement('div');
|
||||
titleLine.className = 'channel-row-title-line';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.className = 'channel-row-title';
|
||||
title.textContent = channel.title;
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
titleLine.append(title, time);
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
||||
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -1204,16 +978,13 @@ function renderChannelMain(channel) {
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
previewLine.append(preview, time);
|
||||
previewLine.append(preview);
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title, technical);
|
||||
main.prepend(titleLine, technical);
|
||||
main.append(previewLine, meta);
|
||||
return main;
|
||||
}
|
||||
@@ -1275,17 +1046,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||
if (!silent) renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
setChannelsFeed(null, {});
|
||||
@@ -1310,12 +1072,18 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
let diaryPayload = null;
|
||||
try {
|
||||
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
} catch {
|
||||
diaryPayload = null;
|
||||
}
|
||||
|
||||
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||
// The server/API implementation is preserved so the feature can be restored later.
|
||||
// See: docs/закомментировано/Личный_дневник.md
|
||||
// let diaryPayload = null;
|
||||
// try {
|
||||
// diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
|
||||
// } catch {
|
||||
// diaryPayload = null;
|
||||
// }
|
||||
const diaryPayload = null;
|
||||
|
||||
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
|
||||
|
||||
listState.channels = toListModel(groups);
|
||||
@@ -1329,6 +1097,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (silent) return;
|
||||
setChannelsFeed(null, {});
|
||||
contentEl.innerHTML = '';
|
||||
|
||||
@@ -1344,39 +1113,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const createSuccessFlash = pullCreateSuccessFlash();
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const listState = {
|
||||
openMenuId: null,
|
||||
topMenuCleanup: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
menuCleanup: null,
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
align: 'left',
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
@@ -1386,39 +1145,45 @@ export function render({ navigate, route, chrome }) {
|
||||
],
|
||||
});
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
|
||||
const topMenuBtn = document.createElement('button');
|
||||
topMenuBtn.type = 'button';
|
||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||
topMenuBtn.title = 'Ещё действия';
|
||||
topMenuBtn.append(createOverflowDots());
|
||||
topMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(topMenuBtn);
|
||||
openTopChannelsMenu({
|
||||
listState,
|
||||
anchorEl: topMenuBtn,
|
||||
navigate,
|
||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||
});
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
topBarRight.append(topMenuBtn);
|
||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
let countersRefreshTimer = null;
|
||||
let lastChannelsUnreadCount = Math.max(0, Number(state.userCounters?.channelsUnreadCount || 0) || 0);
|
||||
const unsubscribeCountersChanged = authService.onEvent('UserCountersChanged', (event) => {
|
||||
const nextChannelsUnreadCount = Math.max(0, Number(event?.payload?.channelsUnreadCount || 0) || 0);
|
||||
if (nextChannelsUnreadCount === lastChannelsUnreadCount) return;
|
||||
lastChannelsUnreadCount = nextChannelsUnreadCount;
|
||||
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
countersRefreshTimer = window.setTimeout(() => {
|
||||
countersRefreshTimer = null;
|
||||
void loadFeedAndRender({ screen, listState, contentEl, navigate, silent: true });
|
||||
}, 120);
|
||||
});
|
||||
|
||||
const rerenderList = () => {
|
||||
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
|
||||
|
||||
renderListContent({
|
||||
screen,
|
||||
container: contentEl,
|
||||
@@ -1429,30 +1194,25 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
screen.append(contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
rerenderList();
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
unsubscribeCountersChanged();
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
+231
-302
@@ -1,4 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
@@ -32,7 +33,18 @@ import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } f
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
bottomFadeAnchor: 'composer',
|
||||
contentUnderTopbar: true,
|
||||
contentUnderBottom: true,
|
||||
scrollContainer: 'nested',
|
||||
},
|
||||
};
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function menuIconSvg(name) {
|
||||
@@ -49,115 +61,108 @@ function menuIconSvg(name) {
|
||||
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
|
||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||
<span>Связи</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
function normalizeChatRelationType(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const layer = root.querySelector('#chat-user-menu-layer');
|
||||
const menu = root.querySelector('.dm-user-identity-menu');
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!menu) return;
|
||||
const width = menu.offsetWidth || 190;
|
||||
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
});
|
||||
|
||||
layer?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === layer) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
function chatRelationLabel(value) {
|
||||
switch (normalizeChatRelationType(value)) {
|
||||
case 'close_friend': return 'Близкий друг';
|
||||
case 'friend': return 'Друг';
|
||||
case 'contact': return 'Контакт';
|
||||
default: return 'Не в контактах';
|
||||
}
|
||||
}
|
||||
|
||||
function createChatHeaderParts(login, navigate) {
|
||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||
let currentPeer = {
|
||||
login: cleanLogin,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
avatar: null,
|
||||
relationType: 'none',
|
||||
};
|
||||
|
||||
const identityButton = document.createElement('button');
|
||||
identityButton.type = 'button';
|
||||
identityButton.className = 'chat-header-peer-btn';
|
||||
identityButton.title = `Меню ${cleanLogin}`;
|
||||
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||
|
||||
const avatarSlot = document.createElement('span');
|
||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||
const initialAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.append(initialAvatar);
|
||||
avatarSlot.className = 'chat-header-avatar-slot';
|
||||
const textWrap = document.createElement('span');
|
||||
textWrap.className = 'chat-header-peer-text';
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'chat-header-peer-name';
|
||||
const metaEl = document.createElement('span');
|
||||
metaEl.className = 'chat-header-peer-meta';
|
||||
textWrap.append(nameEl, metaEl);
|
||||
identityButton.append(avatarSlot, textWrap);
|
||||
|
||||
const avatarButton = document.createElement('button');
|
||||
avatarButton.type = 'button';
|
||||
avatarButton.className = 'chat-header-avatar-btn';
|
||||
avatarButton.title = `Меню ${cleanLogin}`;
|
||||
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||
avatarButton.append(avatarSlot);
|
||||
|
||||
const loginEl = document.createElement('button');
|
||||
loginEl.type = 'button';
|
||||
loginEl.className = 'chat-header-login chat-header-login-btn';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
loginEl.textContent = cleanLogin;
|
||||
|
||||
void loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
if (!avatarSlot.isConnected) return;
|
||||
const upgradedAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName: String(snapshot?.firstName || '').trim(),
|
||||
lastName: String(snapshot?.lastName || '').trim(),
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.replaceChildren(upgradedAvatar);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const openMenu = (event) => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||
const renderPeer = () => {
|
||||
const firstName = String(currentPeer?.firstName || '').trim();
|
||||
const lastName = String(currentPeer?.lastName || '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
nameEl.textContent = fullName || cleanLogin;
|
||||
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||
const avatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar: currentPeer?.avatar?.ar
|
||||
? {
|
||||
ar: String(currentPeer.avatar.ar || '').trim(),
|
||||
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.replaceChildren(avatar);
|
||||
};
|
||||
avatarButton.addEventListener('click', openMenu);
|
||||
loginEl.addEventListener('click', openMenu);
|
||||
|
||||
return { centerNode: loginEl, avatarButton };
|
||||
const updatePeer = (peer) => {
|
||||
if (!peer || typeof peer !== 'object') return;
|
||||
currentPeer = {
|
||||
...currentPeer,
|
||||
...peer,
|
||||
login: String(peer.login || cleanLogin).trim() || cleanLogin,
|
||||
relationType: normalizeChatRelationType(peer.relationType),
|
||||
};
|
||||
renderPeer();
|
||||
};
|
||||
|
||||
renderPeer();
|
||||
const identityMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: identityButton,
|
||||
placement: 'bottom-start',
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{
|
||||
label: 'Показать связи',
|
||||
iconSrc: '/assets/SHiNE_connections_blue.svg',
|
||||
action: () => navigate(makeProfileLinksRoute(cleanLogin)),
|
||||
},
|
||||
{
|
||||
label: 'Показать профиль',
|
||||
iconSrc: '/assets/profile-icon-profile.svg',
|
||||
action: () => navigate(makeProfileRoute(cleanLogin)),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
center: identityButton,
|
||||
updatePeer,
|
||||
getPeer: () => ({ ...currentPeer }),
|
||||
cleanup: () => identityMenu.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
@@ -234,18 +239,30 @@ function openChatConfirmModal({
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
||||
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
const relation = normalizeChatRelationType(relationType);
|
||||
const isCloseFriend = relation === 'close_friend';
|
||||
const isFriend = relation === 'friend';
|
||||
const isProtectedRelation = isCloseFriend || isFriend;
|
||||
const relationName = isCloseFriend ? 'близких друзей' : 'друзей';
|
||||
const safeName = String(contactName || '').trim() || 'этого пользователя';
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-delete-chat-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">Удалить чат?</h3>
|
||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
||||
<label class="dm-confirm-check">
|
||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||
<span>Также удалить всю историю переписки</span>
|
||||
</label>
|
||||
${isProtectedRelation ? `
|
||||
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
|
||||
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
|
||||
` : `
|
||||
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
|
||||
<label class="dm-confirm-check">
|
||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||
<span>Также удалить всю историю переписки</span>
|
||||
</label>
|
||||
`}
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||
@@ -260,10 +277,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
||||
|
||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
||||
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||
const deleteHistory = isProtectedRelation
|
||||
? true
|
||||
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||
close();
|
||||
if (typeof onConfirm === 'function') {
|
||||
await onConfirm({ deleteHistory });
|
||||
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -370,84 +389,6 @@ function openMessageActionsMenu({
|
||||
});
|
||||
}
|
||||
|
||||
function openChatActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
onCall,
|
||||
onVideoCall,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
|
||||
const menuId = `chat-header-actions-menu-${Date.now()}`;
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = root.querySelector(`#${menuId}`);
|
||||
if (!menu) return;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (menu.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
const left = Math.min(
|
||||
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
|
||||
Math.max(12, viewportWidth - menuRect.width - 12)
|
||||
);
|
||||
const top = Math.min(
|
||||
Math.max(12, Number(anchorY || 0) + 10),
|
||||
Math.max(12, viewportHeight - menuRect.height - 12)
|
||||
);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
|
||||
menu.classList.add('is-visible');
|
||||
});
|
||||
|
||||
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onCall === 'function') await onCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
});
|
||||
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onDeleteChat === 'function') await onDeleteChat();
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -802,7 +743,7 @@ function renderLog(
|
||||
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
|
||||
const replyBox = document.createElement('button');
|
||||
replyBox.type = 'button';
|
||||
replyBox.className = 'bubble-reply-preview';
|
||||
replyBox.className = 'ui-button bubble-reply-preview';
|
||||
|
||||
const replyAuthor = document.createElement('div');
|
||||
replyAuthor.className = 'bubble-reply-preview-author';
|
||||
@@ -947,7 +888,6 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
document.body.classList.add('chat-topbar-overlay');
|
||||
const routeChatId = route.params.chatId || 'u1';
|
||||
const chatId = normalizeDmChatId(routeChatId) || 'u1';
|
||||
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
|
||||
@@ -960,8 +900,8 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.className = 'stack dm-screen dm-chat-screen';
|
||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
let peerRelationType = isKnownContact ? 'contact' : 'none';
|
||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
|
||||
let historyHasMore = true;
|
||||
let historyLoading = false;
|
||||
let historyNextBeforeTimeMs = 0;
|
||||
@@ -969,16 +909,8 @@ export function render({ navigate, route, chrome }) {
|
||||
let historyBootstrapped = false;
|
||||
let boundScrollContainer = null;
|
||||
let unreadSeparatorVisible = hasUnreadIncoming;
|
||||
let unreadSeparatorHideTimer = null;
|
||||
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
||||
|
||||
const clearUnreadSeparatorHideTimer = () => {
|
||||
if (unreadSeparatorHideTimer) {
|
||||
window.clearTimeout(unreadSeparatorHideTimer);
|
||||
unreadSeparatorHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
@@ -987,13 +919,9 @@ export function render({ navigate, route, chrome }) {
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
};
|
||||
|
||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorVisible = false;
|
||||
if (rerender) {
|
||||
@@ -1001,14 +929,6 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUnreadSeparatorAutoHide = () => {
|
||||
clearUnreadSeparatorHideTimer();
|
||||
if (!unreadSeparatorVisible) return;
|
||||
unreadSeparatorHideTimer = window.setTimeout(() => {
|
||||
hideUnreadSeparator({ rerender: true });
|
||||
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
|
||||
};
|
||||
|
||||
const handleReadAloud = async (msg) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog();
|
||||
@@ -1054,6 +974,24 @@ export function render({ navigate, route, chrome }) {
|
||||
notifyUnreadStateUpdated();
|
||||
};
|
||||
|
||||
const addPeerToContacts = async () => {
|
||||
const approved = await openConfirmContactModal(chatId);
|
||||
if (!approved) return;
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind: 'contact',
|
||||
enabled: true,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
peerRelationType = 'contact';
|
||||
chatHeaderParts?.updatePeer?.({ relationType: 'contact' });
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Добавлено в контакты', { timeoutMs: 1200 });
|
||||
};
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
const historyLoader = document.createElement('div');
|
||||
@@ -1070,10 +1008,10 @@ export function render({ navigate, route, chrome }) {
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [
|
||||
const chatHeader = createTopBar({
|
||||
center: chatHeaderParts.center,
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
actions: [
|
||||
{
|
||||
title: 'Позвонить',
|
||||
ariaLabel: 'Позвонить',
|
||||
@@ -1086,14 +1024,28 @@ export function render({ navigate, route, chrome }) {
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
onClick: (event) => {
|
||||
openChatActionsMenu({
|
||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
onCall: () => handleStartCall('audio'),
|
||||
onVideoCall: () => handleStartCall('video'),
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
menu: {
|
||||
minWidth: 230,
|
||||
items: () => [
|
||||
{ label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') },
|
||||
{ label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') },
|
||||
normalizeChatRelationType(peerRelationType) === 'none'
|
||||
? {
|
||||
label: 'Добавить в контакты',
|
||||
iconHtml: '<span aria-hidden="true">+</span>',
|
||||
action: async () => {
|
||||
try {
|
||||
await addPeerToContacts();
|
||||
} catch (error) {
|
||||
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
label: 'Очистить историю',
|
||||
iconHtml: menuIconSvg('clear'),
|
||||
action: () => openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
|
||||
confirmLabel: 'Очистить',
|
||||
@@ -1106,81 +1058,56 @@ export function render({ navigate, route, chrome }) {
|
||||
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
onDeleteChat: async () => {
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) {
|
||||
await clearConversationHistory();
|
||||
{
|
||||
label: 'Удалить чат',
|
||||
iconHtml: menuIconSvg('delete'),
|
||||
danger: true,
|
||||
action: () => {
|
||||
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
relationType: relationBeforeDelete,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) await clearConversationHistory();
|
||||
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||
? ['close_friend', 'friend', 'contact']
|
||||
: ['contact'];
|
||||
for (const kind of relationKinds) {
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind,
|
||||
enabled: false,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
}
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(
|
||||
contactsPayload?.contacts
|
||||
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|
||||
|| [],
|
||||
);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Чат удалён', { timeoutMs: 1200 });
|
||||
navigate('messages-list');
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind: 'contact',
|
||||
enabled: false,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(contactsPayload?.contacts || []);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
||||
navigate('messages-list');
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
||||
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
||||
chatHeader.addCleanup(chatHeaderParts.cleanup);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'secondary-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Добавить собеседника в контакты';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const approved = await openConfirmContactModal(chatId);
|
||||
if (!approved) return;
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind: 'contact',
|
||||
enabled: true,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(contactsPayload?.contacts || []);
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'contacts',
|
||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
||||
});
|
||||
card.remove();
|
||||
} catch (e) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'contacts',
|
||||
message: 'Не удалось добавить пользователя в контакты',
|
||||
details: { login: chatId, error: e?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
});
|
||||
card.append(btn);
|
||||
screen.append(card);
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'chat-input dm-chat-input';
|
||||
@@ -1423,12 +1350,19 @@ export function render({ navigate, route, chrome }) {
|
||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||
const text = safeText.trim();
|
||||
if (!text) return;
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
const editing = activeEdit;
|
||||
const replying = !editing ? activeReply : null;
|
||||
const finalText = editing
|
||||
? `${String(editing?.prefixText || '')}${text}`
|
||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||
|
||||
// A new outgoing message immediately ends the visual "Новые сообщения" section.
|
||||
// Do this before awaiting the server: the divider is a UI-session marker, not a
|
||||
// delivery/read receipt indicator. Editing an existing message does not clear it.
|
||||
if (!editing) {
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
}
|
||||
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderChatLog({ scrollMode: 'latest', markAsRead: false });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
@@ -1572,6 +1506,10 @@ export function render({ navigate, route, chrome }) {
|
||||
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
|
||||
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
|
||||
});
|
||||
if (payload?.peer) {
|
||||
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
|
||||
chatHeaderParts.updatePeer(payload.peer);
|
||||
}
|
||||
await mergeDirectMessagesPage(chatId, payload?.messages || []);
|
||||
historyHasMore = Boolean(payload?.hasMore);
|
||||
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
|
||||
@@ -1677,13 +1615,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleIncomingChatRefresh = async (event) => {
|
||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (Number(event?.detail?.messageType || 0) === 1) {
|
||||
if (!unreadSeparatorVisible) {
|
||||
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
||||
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
||||
}
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
// Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт
|
||||
// новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к
|
||||
// непрочитанным, которые существовали ДО открытия экрана чата.
|
||||
preserveComposerSelection(input, () => {
|
||||
renderChatLog({ scrollMode: 'latest' });
|
||||
});
|
||||
@@ -1708,10 +1642,8 @@ export function render({ navigate, route, chrome }) {
|
||||
markAsRead: false,
|
||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||
showUnreadSeparator: unreadSeparatorVisible,
|
||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||
});
|
||||
if (unreadSeparatorVisible) {
|
||||
scheduleUnreadSeparatorAutoHide();
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (markChatRead(chatId) > 0) {
|
||||
notifyUnreadStateUpdated();
|
||||
@@ -1729,9 +1661,6 @@ export function render({ navigate, route, chrome }) {
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
chrome?.setComposer(null);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Подключить устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('device-view') },
|
||||
}),
|
||||
);
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
@@ -148,6 +146,11 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(card, helpModal);
|
||||
screen.append(card);
|
||||
const modalRoot = document.getElementById('modal-root');
|
||||
modalRoot?.append(helpModal);
|
||||
screen.cleanup = () => {
|
||||
helpModal.remove();
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user