diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index d2aa2e25..b04a25b3 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -112,7 +112,7 @@ public final class MsgSubType { /** Удалить из контактов. */ public static final short CONNECTION_UNCONTACT = 21; - // Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE. + // FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE. /** Подписаться (follow). */ public static final short CONNECTION_FOLLOW = 30; /** Отписаться (unfollow). */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 8d5656c6..fc9f2b92 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -34,6 +34,8 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_15 = 15; public static final int SCHEMA_VERSION_16 = 16; public static final int SCHEMA_VERSION_17 = 17; + public static final int SCHEMA_VERSION_18 = 18; + public static final int SCHEMA_VERSION_19 = 19; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -51,6 +53,8 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql"; public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql"; public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql"; + public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql"; + public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; private DatabaseInitializer() {} @@ -78,7 +82,7 @@ public final class DatabaseInitializer { public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_UNCONTACT = 21; - // Пока не используется в UI. DO NOT REMOVE. + // FOLLOW на публичный канал используется; user-to-user FOLLOW legacy. DO NOT REMOVE. public static final short CONNECTION_FOLLOW = 30; public static final short CONNECTION_UNFOLLOW = 31; @@ -188,6 +192,14 @@ public final class DatabaseInitializer { 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; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 78e061d5..850244a6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -82,7 +82,7 @@ public final class MsgSubType { /** Удалить из контактов. */ public static final short CONNECTION_UNCONTACT = 21; - // Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE. + // FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE. /** Подписаться (follow). */ public static final short CONNECTION_FOLLOW = 30; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index cb065341..2cfc5645 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -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, diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java index fe417830..d75a6ea4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java @@ -64,6 +64,7 @@ public final class DmDialogStateDAO { Map byPeer = new LinkedHashMap<>(); List contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT); + List friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND); List closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND); try (PreparedStatement ps = c.prepareStatement(""" @@ -95,6 +96,9 @@ public final class DmDialogStateDAO { for (String peer : contacts) { addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false); } + for (String peer : friends) { + addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false); + } for (String peer : closeFriends) { addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false); } @@ -286,6 +290,9 @@ public final class DmDialogStateDAO { if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) { return "close_friend"; } + if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) { + return "friend"; + } if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) { return "contact"; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java index 9166759b..ed063f40 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java @@ -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 { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java new file mode 100644 index 00000000..901e8b20 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java @@ -0,0 +1,117 @@ +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; + +/** 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); + } + } + } + + public List 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 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 listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException { + int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset); + String sql; + if ("owned".equals(mode)) { + sql=""" + SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash + FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1 + ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ? + """; + } else if ("following".equals(mode)) { + sql=""" + SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash + FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash + WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1 + ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ? + """; + } else throw new IllegalArgumentException("Unsupported channel mode: "+mode); + List 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){} +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v18.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v18.sql new file mode 100644 index 00000000..2f67bfb0 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v18.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v19.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v19.sql new file mode 100644 index 00000000..e557790b --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v19.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 4d0d9671..2a6cf07f 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + VALUES (1, 18, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -1646,4 +1646,359 @@ AFTER INSERT ON channel_names_state FOR EACH ROW EXECUTE FUNCTION shine_channel_names_state_stats_ai(); + +-- v18 integrated fresh-schema definitions +-- v18: fast profile projection, effective social/voting statistics and qualified likes. +-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state. + +CREATE TABLE IF NOT EXISTS user_profile_state ( + login TEXT PRIMARY KEY, + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + ava_ar TEXT NOT NULL DEFAULT '', + account_role TEXT, + shine_status TEXT, + updated_at_ms BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_user_profile_state_name + ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login)); + +ALTER TABLE user_stats_state + ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0), + ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0), + ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0), + ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0), + ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0); + +ALTER TABLE message_stats + ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0), + ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0); + +CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_first_name TEXT := ''; + v_last_name TEXT := ''; + v_ava_ar TEXT := ''; + v_account_role TEXT := NULL; + v_shine TEXT := NULL; + v_updated BIGINT := 0; +BEGIN + IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF; + + SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''), + COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''), + COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''), + MAX(value) FILTER (WHERE param = 'account_role'), + MAX(value) FILTER (WHERE param = 'shine'), + COALESCE(MAX(time_ms), 0) + INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated + FROM users_params + WHERE LOWER(login) = LOWER(p_login); + + v_account_role := LOWER(BTRIM(COALESCE(v_account_role, ''))); + IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF; + + v_shine := LOWER(BTRIM(COALESCE(v_shine, ''))); + IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility + IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF; + + INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms) + VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated) + ON CONFLICT (login) DO UPDATE SET + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + ava_ar = EXCLUDED.ava_ar, + account_role = EXCLUDED.account_role, + shine_status = EXCLUDED.shine_status, + updated_at_ms = EXCLUDED.updated_at_ms; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE) +$$; + +CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE) +$$; + +CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE) +$$; + +CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE) +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); +BEGIN + IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM solana_user_pda_current + WHERE login = p_login + LIMIT 1 + ) THEN + RETURN; + END IF; + + INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count, + following_channels_count, close_friends_count, friends_count, + primary_confirmations_received_count, primary_confirmations_given_count, + shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms) + SELECT p_login, + (SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1), + 0, + (SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn + ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash + WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1), + (SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10), + (SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs + WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14 + AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)), + (SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs + WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80 + AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)), + (SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs + WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80 + AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)), + (SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs + WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70 + AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)), + (SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs + WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70 + AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)), + v_now + ON CONFLICT (login) DO UPDATE SET + owned_public_channels_count=EXCLUDED.owned_public_channels_count, + following_users_count=0, + following_channels_count=EXCLUDED.following_channels_count, + close_friends_count=EXCLUDED.close_friends_count, + friends_count=EXCLUDED.friends_count, + primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count, + primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count, + shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count, + shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count, + updated_at_ms=EXCLUDED.updated_at_ms; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE r RECORD; +BEGIN + PERFORM shine_refresh_user_stats(p_login); + FOR r IN + SELECT DISTINCT x.login FROM ( + SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80) + UNION + SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80) + ) x + LOOP + PERFORM shine_refresh_user_stats(r.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE r RECORD; +BEGIN + FOR r IN + SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash + FROM reactions_state rs + WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1 + LOOP + UPDATE message_stats ms SET + primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 + AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash + AND shine_is_primary(x.from_login)), + shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 + AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash + AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login)) + WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash; + END LOOP; +END; +$$; + + +CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_owner_login TEXT; + v_channel_type INTEGER; + v_count INTEGER := 0; + v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT); +BEGIN + IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF; + + SELECT cn.owner_login, cn.channel_type_code + INTO v_owner_login, v_channel_type + FROM channel_names_state cn + WHERE cn.owner_bch_name=p_bch_name + AND cn.channel_root_block_number=p_block_number + AND cn.channel_root_block_hash=p_block_hash + LIMIT 1; + + IF v_channel_type IS NULL THEN RETURN; END IF; + + SELECT COUNT(DISTINCT LOWER(cs.login))::INT + INTO v_count + FROM connections_state cs + WHERE cs.rel_type=30 + AND cs.to_bch_name=p_bch_name + AND cs.to_block_number=p_block_number + AND cs.to_block_hash=p_block_hash; + + INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms) + VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now) + ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET + owner_login=EXCLUDED.owner_login, + channel_type_code=EXCLUDED.channel_type_code, + subscribers_count=EXCLUDED.subscribers_count, + updated_at_ms=EXCLUDED.updated_at_ms; +END; +$$; + +-- Replace incremental channel-name counter with idempotent recomputation. +CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.channel_type_code = 1 THEN + PERFORM shine_refresh_user_stats(NEW.owner_login); + END IF; + RETURN NEW; +END; +$$; + +-- Rebuild connection state without incremental user counters; then recompute affected users. +CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; + positive_rel_type INTEGER; +BEGIN + IF NEW.msg_type <> 3 THEN RETURN NEW; END IF; + resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name); + IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF; + + IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN + DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login + AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0) + AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')); + INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash) + VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'))); + ELSE + positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40 + WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END; + IF positive_rel_type IS NULL THEN RETURN NEW; END IF; + DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login + AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0) + AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')); + END IF; + + PERFORM shine_refresh_user_stats(NEW.login); + PERFORM shine_refresh_user_stats(resolved_login); + IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN + PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE previous_sub_type INTEGER; +BEGIN + IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF; + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF; + + INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count) + VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0) + ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING; + + SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1 + AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1; + + IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN + UPDATE message_stats SET likes_count=likes_count+1, + primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END, + shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END + WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash; + ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN + UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1), + primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END), + shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END) + WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash; + END IF; + + INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type) + VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type) + ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type; + RETURN NEW; +END; +$$; + +-- Initial projection/rebuild. +DO $$ DECLARE r RECORD; BEGIN + FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP; +END $$; +DO $$ DECLARE r RECORD; BEGIN + FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP; +END $$; +DO $$ DECLARE r RECORD; BEGIN + FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP + PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash); + END LOOP; +END $$; + +UPDATE message_stats ms SET + primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 + AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash + AND shine_is_primary(rs.from_login)), + shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 + AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash + AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login)); + +INSERT INTO db_schema_version(id,schema_version,updated_at_ms) +VALUES(1,18,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; + COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 4e2cf7c2..d9986b93 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -88,6 +88,10 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalD import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; +import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; +import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler; +import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request; +import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler; @@ -207,6 +211,8 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), + Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()), + Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), Map.entry("GetNotifications", new Net_GetNotifications_Handler()), @@ -295,6 +301,8 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), + Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), + Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), Map.entry("GetNotifications", Net_GetNotifications_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index b0f3701d..c074dcbd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -563,6 +563,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { be.setEditedByBlockNumber(be.getToBlockNumber()); } + if (block.body instanceof ConnectionBody connectionBody) { + String votingError = validateConnectionVotingRights(login, block.subType & 0xFFFF, connectionBody.toLogin()); + if (votingError != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, votingError, serverLastNum, serverLastHashHex); + } + } + UserParamEntry upsertedParam = null; if (block.body instanceof UserParamBody upBody) { String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank()) @@ -609,6 +616,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { /* ====================== Helpers ====================================== */ /* ===================================================================== */ + private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) { + if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) + && subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null; + if (targetLogin == null || targetLogin.isBlank()) return "bad_connection_target"; + String sql = subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) + ? "SELECT shine_is_primary(?), shine_target_accepts_primary_confirmation(?)" + : "SELECT (shine_is_primary(?) AND shine_is_shining(?)), shine_target_accepts_shine_confirmation(?)"; + try (Connection c = shine.db.DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + if (subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)) { + ps.setString(1, actorLogin); ps.setString(2, targetLogin); + } else { + ps.setString(1, actorLogin); ps.setString(2, actorLogin); ps.setString(3, targetLogin); + } + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next() || !rs.getBoolean(1)) return subType == 80 ? "primary_vote_not_allowed" : "shine_vote_not_allowed"; + if (!rs.getBoolean(2)) return subType == 80 ? "target_non_voting" : "target_shine_not_interested"; + } + return null; + } catch (Exception e) { + log.warn("Connection voting permission check failed actor={} target={} subtype={}", actorLogin, targetLogin, subType, e); + return "voting_permission_check_failed"; + } + } + private static byte[] decodeBase64(String b64) { if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null"); return Base64Ws.decode(b64); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java index 84576500..9a02cf29 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java @@ -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()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 01d5e866..b0cfce4d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -342,8 +342,10 @@ final class ChannelsReadSupport { } static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException { - String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1"; + String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1"; int likesCount = 0; + int primaryLikesCount = 0; + int shiningLikesCount = 0; int repliesCount = 0; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, bch); @@ -352,6 +354,8 @@ final class ChannelsReadSupport { try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { likesCount = rs.getInt("likes_count"); + primaryLikesCount = rs.getInt("primary_likes_count"); + shiningLikesCount = rs.getInt("shining_likes_count"); repliesCount = rs.getInt("replies_count"); } } @@ -378,7 +382,7 @@ final class ChannelsReadSupport { } } } - return new int[] {likesCount, repliesCount, ratingsCount}; + return new int[] {likesCount, repliesCount, ratingsCount, primaryLikesCount, shiningLikesCount}; } static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 23bc7042..bb95d644 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -169,6 +169,8 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash); item.setLikesCount(stats[0]); + item.setPrimaryLikesCount(stats[3]); + item.setShiningLikesCount(stats[4]); item.setRepliesCount(stats[1]); item.setRatingsCount(stats[2]); item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash)); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index d86af517..5405f803 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -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)); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 811d4c6c..9d3d5c5b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -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; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java index 88bce966..69e4c736 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java @@ -65,10 +65,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { // Legacy 60/61 are accepted by the protocol but intentionally not surfaced by current UI. List outKnownPersons = List.of(); List inKnownPersons = List.of(); - List outOfficialAccounts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED); - List inOfficialAccounts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED); - List outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED); - List inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED); + List outOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, true); + List inOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, false); + List outShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, true); + List inShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, false); List outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN); List inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN); @@ -117,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 relationTypes = buildEffectiveRelationTypes(outContacts, outFriends, outCloseFriends); + resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin, relationTypes, outOfficialAccounts, outShineConfirmed)); return resp; } } + private List 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 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"; @@ -169,47 +207,54 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { private Map loadUserMeta(Connection c, Set logins) throws Exception { Map 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 buildEffectiveRelationTypes(List contacts,List friends,List closeFriends){ + Map 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 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); @@ -278,7 +323,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { private List toUserMarkItems( Set logins, - Map metaByLogin + Map metaByLogin, + Map relationTypes, + List primaryConfirmedLogins, + List shineConfirmedLogins ) { List items = new ArrayList<>(); if (logins == null) return items; @@ -293,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 ? "официальный" : "неофициальный"); @@ -305,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; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java index 52888466..3953be7e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java @@ -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,24 @@ 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 toDialogItems(List dialogs) { + private List toDialogItems(Connection c, List dialogs) throws Exception { List 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.setAccountRole(card.accountRole()); + item.setShineStatus(card.shineStatus()); item.setLastMessageBlobB64(dialog.lastMessageBlobB64()); item.setLastMessageTimeMs(dialog.lastMessageTimeMs()); item.setUnreadCount(dialog.unreadCount()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java index dbb41db1..285d44dc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java @@ -62,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; @@ -70,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; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java index ff827a23..0f77f2f8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java @@ -17,6 +17,11 @@ 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 String accountRole; + private String shineStatus; private String lastMessageBlobB64; private long lastMessageTimeMs; private int unreadCount; @@ -26,6 +31,16 @@ 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 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; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileChannels_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileChannels_Handler.java new file mode 100644 index 00000000..4f14722b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileChannels_Handler.java @@ -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 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;}}} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileRelations_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileRelations_Handler.java new file mode 100644 index 00000000..417a5073 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/Net_ListUserProfileRelations_Handler.java @@ -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 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; + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Request.java new file mode 100644 index 00000000..c466c405 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Request.java @@ -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;}} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Response.java new file mode 100644 index 00000000..f90d99aa --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileChannels_Response.java @@ -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 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 getChannels(){return channels;}public void setChannels(List 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;}}} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Request.java new file mode 100644 index 00000000..4b2619db --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Request.java @@ -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;} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Response.java new file mode 100644 index 00000000..075e269d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/profile/entyties/Net_ListUserProfileRelations_Response.java @@ -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 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 getUsers(){return users;} public void setUsers(List 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;} + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 1298d603..3f2dfa0d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -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); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java index c9c0da10..aa134b78 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java @@ -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; } } diff --git a/VERSION.properties b/VERSION.properties index a9da8404..2109df33 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.10.2 -server.version=1.8.1 +client.version=1.10.3 +server.version=1.8.2 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 7cd54351..cfe20e5a 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -416,3 +416,31 @@ - `limit_too_large` - `channel_name_already_exists` - `internal_error` + +--- + +## Profile public-channel lists (v18) + +### `ListUserProfileChannels` + +Returns lightweight public-channel cards for profile counters. `mode` is `owned` or `following`; only `channel_type_code=1` is returned. + +```json +{ + "op": "ListUserProfileChannels", + "requestId": "profile-ch-1", + "payload": { "login": "Alice", "mode": "owned", "limit": 100, "offset": 0 } +} +``` + +Channel items contain `ownerLogin`, `slug`, `displayName`, `avatarAr`, `ownerBlockchainName`, `rootBlockNumber`, and `rootBlockHashHex`. + +## Qualified likes (v18) + +Channel/message responses expose three counters: + +- `likesCount` — all active likes; +- `primaryLikesCount` — likes from users whose current `account_role=primary`; +- `shiningLikesCount` — likes from users who are currently both `primary` and `shining`. + +Changing `account_role` or `shine` does not alter the raw reaction. The two qualified counters are recalculated from `reactions_state`; therefore old likes automatically enter or leave qualified counters when the actor's current status changes. diff --git a/docs/API/11_Connections_API.md b/docs/API/11_Connections_API.md index b33c574a..f94b351d 100644 --- a/docs/API/11_Connections_API.md +++ b/docs/API/11_Connections_API.md @@ -132,8 +132,6 @@ "login": "Alice", "outFriends": ["Bob"], "inFriends": ["Kate"], - "outCloseFriends": [], - "inCloseFriends": [], "outContacts": [], "inContacts": [], "outFollows": [], @@ -148,8 +146,6 @@ "inSiblings": [], "outKnownPersons": [], "inKnownPersons": [], - "outOfficialAccounts": [], - "inOfficialAccounts": [], "outShineConfirmed": [], "inShineConfirmed": [], "outShineSeen": [], @@ -175,7 +171,6 @@ ### Примечание Поля `known_person`, `shine_confirmed`, `shine_seen` в UI считаются недопроверенной зоной проекта; при изменениях этой логики нужна ручная end-to-end проверка. -`outCloseFriends` / `inCloseFriends` возвращают близких друзей, а `outOfficialAccounts` / `inOfficialAccounts` - подтверждённые официальные аккаунты. --- @@ -210,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`. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index eb3f9eee..428a1db5 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -91,6 +91,8 @@ import * as messagesList from './pages/messages-list.js?v=202608221218'; import * as contactSearchView from './pages/contact-search-view.js'; import * as chatView from './pages/chat-view.js?v=202608221218'; import * as userProfileView from './pages/user-profile-view.js'; +import * as userProfileListView from './pages/user-profile-list-view.js'; +import * as userRelationManageView from './pages/user-relation-manage-view.js'; import * as channelsList from './pages/channels-list.js?v=202608221218'; import * as channelView from './pages/channel-view.js'; import * as channelAboutView from './pages/channel-about-view.js'; @@ -154,6 +156,8 @@ const routes = { 'contact-search-view': contactSearchView, 'chat-view': chatView, user: userProfileView, + 'user-profile-list-view': userProfileListView, + 'user-relation-manage-view': userRelationManageView, 'channels-list': channelsList, 'channel-view': channelView, 'channel-about-view': channelAboutView, diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index a4082ea5..2fa3ca88 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -775,6 +775,8 @@ function renderNodeCard(node, heading, handlers, localNumber) { const versionsTotal = Number(node?.versionsTotal || versions.length || 1); const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)'); const likes = Number(node?.likesCount || 0); + const primaryLikes = Number(node?.primaryLikesCount || 0); + const shiningLikes = Number(node?.shiningLikesCount || 0); const replies = Number(node?.repliesCount || 0); const ratings = Number(node?.ratingsCount || 0); const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); @@ -918,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} - ${likes} + ${likes} · ${primaryLikes} · ${shiningLikes} `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 8baa95fa..829f25ec 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1417,6 +1417,8 @@ function mapApiMessageToPost(message, selector, localNumber) { versionsTotal: Number(message?.versionsTotal || 1), versions: Array.isArray(message?.versions) ? message.versions : [], likesCount: Number(message?.likesCount || 0), + primaryLikesCount: Number(message?.primaryLikesCount || 0), + shiningLikesCount: Number(message?.shiningLikesCount || 0), repliesCount: Number(message?.repliesCount || 0), ratingsCount: Number(message?.ratingsCount || 0), timestampMs: resolveMessageTimestampMs(message), @@ -1961,7 +1963,7 @@ function renderPostCard(post, { likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} - ${post.likesCount || 0} + ${post.likesCount || 0} · ${post.primaryLikesCount || 0} · ${post.shiningLikesCount || 0} `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; diff --git a/shine-UI/js/pages/user-profile-list-view.js b/shine-UI/js/pages/user-profile-list-view.js new file mode 100644 index 00000000..cc7963b0 --- /dev/null +++ b/shine-UI/js/pages/user-profile-list-view.js @@ -0,0 +1,60 @@ +import { renderHeader } from '../components/header.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { authService } from '../state.js'; +import { navigateBack } from '../router.js'; + +export const pageMeta = { id: 'user-profile-list-view', title: 'Список' }; + +function parseAvatar(raw) { + const value = String(raw || ''); + const m = value.match(/(?:^|,)\s*AR:([A-Za-z0-9_-]{43})(?:,|$)/); + return m ? { ar: m[1] } : null; +} + +const TITLES = { + friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили основной аккаунт', + primary_given: 'Подтверждённые аккаунты', shine_received: 'Считают сияющим', shine_given: 'Подтверждённые сияющие', + channels_owned: 'Каналы', channels_following: 'Подписки на каналы', +}; + +export function render({ navigate, route }) { + const login = String(route?.params?.login || '').trim(); + const kind = String(route?.params?.kind || '').trim(); + const screen = document.createElement('section'); screen.className = 'stack'; + const body = document.createElement('div'); body.className = 'stack'; + const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...'; + screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body); + + (async () => { + try { + if (kind === 'channels_owned' || kind === 'channels_following') { + const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0); + const rows = Array.isArray(payload?.channels) ? payload.channels : []; + rows.forEach((row) => { + const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row'; + el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' })); + const t = document.createElement('div'); t.className = 'profile-list-row-text'; + t.innerHTML = `${String(row.displayName || row.slug || '')}${String(row.ownerLogin || '')} / ${String(row.slug || '')}`; + el.append(t); + el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`)); + body.append(el); + }); + status.textContent = rows.length ? '' : 'Список пуст.'; + return; + } + const payload = await authService.listUserProfileRelations(login, kind, 200, 0); + const rows = Array.isArray(payload?.users) ? payload.users : []; + rows.forEach((row) => { + const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row'; + el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' })); + const fullName = [row.firstName, row.lastName].filter(Boolean).join(' ') || row.login; + const t = document.createElement('div'); t.className = 'profile-list-row-text'; + const marks = [row.relationType && row.relationType !== 'none' ? row.relationType : '', row.primaryConfirmed ? 'основной ✓' : '', row.shineConfirmed ? 'сияющий ✓' : ''].filter(Boolean).join(' · '); + t.innerHTML = `${fullName}${String(row.login || '')}${marks ? ` · ${marks}` : ''}`; + el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el); + }); + status.textContent = rows.length ? '' : 'Список пуст.'; + } catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; } + })(); + return screen; +} diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index 199ac91e..884d6ab2 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -1,371 +1,56 @@ import { renderHeader } from '../components/header.js'; -import { authService, state } from '../state.js'; -import { - buildIdentityLines, - loadRelationsForPair, - loadUserProfileCard, -} from '../services/user-connections.js'; +import { state } from '../state.js'; +import { loadUserProfileCard } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js'; - import { navigateBack } from '../router.js'; -export const pageMeta = { id: 'user', title: 'Чужой профиль' }; +export const pageMeta = { id: 'user', title: 'Профиль пользователя' }; -function escapeHtml(text) { - return String(text || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); +function escapeHtml(text){return String(text||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');} +function fullName(card){return [card.firstName,card.lastName].filter(Boolean).join(' ')||card.login;} + +function openTextModal(title,text){ + const root=document.getElementById('modal-root'); if(!root)return; + root.innerHTML=``; + const close=()=>{root.innerHTML='';}; root.querySelector('#profile-text-close')?.addEventListener('click',close); root.querySelector('#profile-text-modal')?.addEventListener('click',e=>{if(e.target?.id==='profile-text-modal')close();}); } -function openProfileInfoModal({ title, text }) { - const root = document.getElementById('modal-root'); - if (!root) return; - root.innerHTML = ` - - `; - const close = () => { root.innerHTML = ''; }; - root.querySelector('#profile-info-close')?.addEventListener('click', close); - root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => { - if (event.target?.id === 'profile-info-modal') close(); - }); +function statusBadges(card){ + const role=card.accountRole==='primary'?'Основной аккаунт':card.accountRole==='non_voting'?'Голос не учитывать':''; + const shine=card.shineStatus==='shining'?'Сияющий':''; + return `
${role?`${escapeHtml(role)}`:''}${shine?'Сияющий':''}${card.shineStatus==='not_interested'?'Сияние неинтересно':''}
`; } -function officialInfoText() { - return 'Можно создавать несколько альтернативных или анонимных каналов. ' - + 'Но для корректного учёта голосов на одного реального человека используется только один основной аккаунт.'; +function statsHtml(card){ + const s=card.stats||{}; + const rows=[ + ['friends','Друзья',s.friendsCount],['close_friends','Близкие друзья',s.closeFriendsCount], + ['primary_received','Подтвердили основной аккаунт',s.primaryReceivedCount],['primary_given','Подтверждённые аккаунты',s.primaryGivenCount], + ['shine_received','Считают сияющим',s.shineReceivedCount],['shine_given','Подтверждённые сияющие',s.shineGivenCount], + ['channels_following','Подписки на каналы',s.followingChannelsCount],['channels_owned','Каналы',s.ownedPublicChannelsCount], + ]; + return `
${rows.map(([kind,label,n])=>``).join('')}
`; } -function shineInfoText() { - return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n' - + 'Пять принципов сияющих:\n' - + '1) сияющие не обманывают;\n' - + '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n' - + '3) сияющие развиваются и в духовной, и в материальной плоскости;\n' - + '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n' - + '5) сияющие заботятся о мире: о людях, гармонии и общем благе.'; -} - -function genderText(value) { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'male') return 'Мужской'; - if (normalized === 'female') return 'Женский'; - return 'Не указан'; -} - -function relationButtonLabel(kind, flags) { - if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты'; - if (kind === 'friend') return flags.outFriend ? 'Убрать из друзей' : 'Добавить в друзья'; - if (kind === 'close_friend') return flags.outCloseFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья'; - if (kind === 'official_account') return flags.outOfficialAccount ? 'Снять подтверждение' : 'Подтвердить аккаунт'; - if (kind === 'shine_confirmed') return flags.outShineConfirmed ? 'Снять подтверждение' : 'Подтвердить сияющего'; - return ''; -} - -function relationNextState(kind, flags) { - if (kind === 'contact') return !flags.outContact; - if (kind === 'friend') return !flags.outFriend; - if (kind === 'close_friend') return !flags.outCloseFriend; - if (kind === 'official_account') return !flags.outOfficialAccount; - if (kind === 'shine_confirmed') return !flags.outShineConfirmed; - return false; -} - -function relationConfirmLabel(kind) { - if (kind === 'contact') return 'контакт'; - if (kind === 'friend') return 'статус друга'; - if (kind === 'close_friend') return 'статус близкого друга'; - if (kind === 'official_account') return 'подтверждение официального аккаунта'; - if (kind === 'shine_confirmed') return 'подтверждение сияющего'; - return 'связь'; -} - -function relationStateText(kind, flags) { - if (kind === 'contact') { - if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.'; - if (flags.outContact) return 'Вы добавили этот профиль в контакты.'; - if (flags.inContact) return 'Этот профиль добавил вас в контакты.'; - return ''; - } - if (kind === 'friend') { - if (flags.outFriend && flags.inFriend) return 'Вы взаимно считаете друг друга друзьями.'; - if (flags.outFriend) return 'Вы считаете этого человека другом.'; - if (flags.inFriend) return 'Этот человек считает вас другом.'; - return ''; - } - if (kind === 'close_friend') { - if (flags.outCloseFriend && flags.inCloseFriend) return 'Вы взаимно близкие друзья.'; - if (flags.outCloseFriend) return 'Вы считаете этого человека близким другом.'; - if (flags.inCloseFriend) return 'Этот человек считает вас близким другом.'; - return ''; - } - if (kind === 'official_account') { - return flags.outOfficialAccount - ? 'Вы подтверждаете, что это действительно официальный аккаунт этого человека.' - : ''; - } - if (kind === 'shine_confirmed') { - return flags.outShineConfirmed - ? 'Вы подтверждаете, что этот человек сияющий.' - : ''; - } - return ''; -} - -function renderRelations(flags) { - const rows = [ - { kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) }, - { kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) }, - { kind: 'close_friend', text: relationStateText('close_friend', flags), button: relationButtonLabel('close_friend', flags) }, - { kind: 'official_account', text: relationStateText('official_account', flags), button: relationButtonLabel('official_account', flags) }, - { kind: 'shine_confirmed', text: relationStateText('shine_confirmed', flags), button: relationButtonLabel('shine_confirmed', flags) }, - ]; - - return ` -
- ${rows.map((row) => ` -
- ${escapeHtml(row.text)} - -
- `).join('')} -
- `; -} - -function renderIdentity(card) { - const lines = buildIdentityLines({ - login: card.login, - firstName: card.firstName, - lastName: card.lastName, - }); - - const row = document.createElement('div'); - row.className = 'row'; - row.style.gap = '12px'; - row.style.alignItems = 'center'; - - row.append(renderUserAvatar({ - login: card.login, - firstName: card.firstName, - lastName: card.lastName, - avatar: card.avatar, - size: 'xl', - className: 'profile-avatar', - })); - - const identityLines = document.createElement('div'); - identityLines.className = 'profile-identity-lines'; - lines.forEach((line, idx) => { - const lineEl = document.createElement('div'); - lineEl.className = `profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}`; - lineEl.textContent = line; - identityLines.append(lineEl); - }); - row.append(identityLines); - - return row; -} - -function renderReadOnlyBadges(card) { - const accountRole = String(card.accountRole || '').trim().toLowerCase(); - const shineStatus = String(card.shineStatus || '').trim().toLowerCase(); - const accountLabel = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : ''; - const shineLabel = shineStatus === 'shining' ? 'Сияющий' : ''; - return ` -
- ${accountLabel ? `` : ''} - ${shineLabel ? `` : ''} - ${shineStatus === 'not_interested' ? `Сияние неинтересно` : ''} -
- `; -} - -function renderReadOnlyParams(card) { - const rows = [ - { label: 'Имя', value: card.firstName }, - { label: 'Фамилия', value: card.lastName }, - { label: 'Пол', value: genderText(card.gender) }, - { label: 'Адрес', value: card.address }, - { label: 'Web', value: card.web }, - { label: 'Телефон', value: card.phone }, - { label: 'О себе', value: card.about }, - { label: 'Духовный путь', value: card.spiritualPath }, - ]; - - return ` -
- ${rows.map((row) => ` -
-
${row.label}: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}
-
- `).join('')} -
- `; -} - -export function render({ navigate, route }) { - const requestedLogin = String(route.params.login || '').trim(); - const sessionLogin = String(state.session.login || '').trim(); - - const screen = document.createElement('section'); - screen.className = 'stack'; - - const status = document.createElement('div'); - status.className = 'status-line'; - status.textContent = 'Загрузка профиля...'; - - const body = document.createElement('div'); - body.className = 'stack'; - - screen.append( - renderHeader({ - title: 'Профиль пользователя', - leftAction: { label: '←', onClick: () => navigateBack() }, - rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }], - }), - status, - body, - ); - const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn'); - linksHeaderBtn?.classList.add('profile-links-header-btn'); - - let currentCard = null; - let currentFlags = null; - let isBusy = false; - - function syncActionButtons() { - const kinds = ['contact', 'friend', 'close_friend', 'official_account', 'shine_confirmed']; - if (!currentFlags) return; - const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase(); - kinds.forEach((kind) => { - const btn = body.querySelector(`[data-relation-action="${kind}"]`); - if (!btn) return; - btn.textContent = relationButtonLabel(kind, currentFlags); - btn.disabled = Boolean(isSelf); - }); - } - - async function refresh() { - if (!requestedLogin) { - status.className = 'status-line is-unavailable'; - status.textContent = 'Не передан login пользователя.'; - return; - } - - isBusy = true; - status.className = 'status-line'; - status.textContent = 'Загрузка профиля...'; - - try { - const card = await loadUserProfileCard(requestedLogin); - const flags = await loadRelationsForPair({ - currentLogin: sessionLogin, - targetLogin: card.login, - }); - - currentCard = card; - currentFlags = flags; - - body.innerHTML = ` - ${renderReadOnlyBadges(card)} - ${renderRelations(flags)} - ${renderReadOnlyParams(card)} - `; - const identityCard = document.createElement('div'); - identityCard.className = 'card stack'; - identityCard.append(renderIdentity(card)); - body.prepend(identityCard); - - syncActionButtons(); - if (String(route?.params?.section || '').toLowerCase() === 'links') { - const rel = body.querySelector('[data-profile-relations="true"]'); - rel?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - status.className = 'status-line is-available'; - status.textContent = 'Профиль обновлён.'; - } catch (error) { - status.className = 'status-line is-unavailable'; - status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`; - window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`); - } finally { - isBusy = false; - } - } - - async function onRelationAction(kind) { - if (isBusy || !currentCard || !currentFlags) return; - if (!sessionLogin) { - window.alert('Для изменения связей нужен активный вход.'); - return; - } - if (!state.session.storagePwdInMemory) { - window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.'); - return; - } - - const nextEnabled = relationNextState(kind, currentFlags); - const confirmed = window.confirm( - `Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` + - 'Будет отправлен AddBlock CONNECTION.', - ); - if (!confirmed) return; - - isBusy = true; - status.className = 'status-line'; - status.textContent = 'Сохранение отношения в блокчейн...'; - - try { - await authService.setUserRelation({ - login: sessionLogin, - toLogin: currentCard.login, - kind, - enabled: nextEnabled, - storagePwd: state.session.storagePwdInMemory, - }); - await refresh(); - } catch (error) { - status.className = 'status-line is-unavailable'; - status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`; - window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`); - isBusy = false; - } - } - - body.addEventListener('click', (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - const infoBtn = target.closest('[data-profile-info]'); - const infoKind = String(infoBtn?.getAttribute('data-profile-info') || ''); - if (infoKind === 'official') { - openProfileInfoModal({ - title: 'Основной аккаунт', - text: officialInfoText(), - }); - return; - } - if (infoKind === 'shine') { - openProfileInfoModal({ - title: 'Справка о сияющих', - text: shineInfoText(), - }); - return; - } - const actionBtn = target.closest('[data-relation-action]'); - const kind = String(actionBtn?.getAttribute('data-relation-action') || ''); - if (!kind) return; - void onRelationAction(kind); - }); - - refresh(); - return screen; +export function render({navigate,route}){ + const requestedLogin=String(route?.params?.login||'').trim(); const selfLogin=String(state.session.login||'').trim(); + const screen=document.createElement('section'); screen.className='stack'; const body=document.createElement('div');body.className='stack';const status=document.createElement('div');status.className='status-line';status.textContent='Загрузка профиля...'; + screen.append(renderHeader({title:'Профиль пользователя',leftAction:{label:'←',onClick:()=>navigateBack()}}),status,body); + let card=null; + async function refresh(){ + card=await loadUserProfileCard(requestedLogin); const isSelf=card.login.toLowerCase()===selfLogin.toLowerCase(); + body.innerHTML=`${statusBadges(card)}
${escapeHtml(card.about||'')}
${statsHtml(card)} +
+ ${!isSelf?`
`:''}`; + const identity=document.createElement('div');identity.className='card row';identity.style.gap='12px';identity.style.alignItems='center'; + identity.append(renderUserAvatar({login:card.login,firstName:card.firstName,lastName:card.lastName,avatar:card.avatar,size:'xl',className:'profile-avatar'})); + const txt=document.createElement('div');txt.innerHTML=`
${escapeHtml(fullName(card))}
`;identity.append(txt);body.prepend(identity);status.textContent=''; + } + body.addEventListener('click',e=>{const el=e.target.closest('[data-profile-list],[data-profile-detail],[data-bottom]');if(!el||!card)return; + const list=el.dataset.profileList;if(list){navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(list)}`);return;} + const detail=el.dataset.profileDetail;if(detail==='contacts'){openTextModal('Контакты',[card.web?`Links: ${card.web}`:'',card.phone?`Телефон: ${card.phone}`:'',card.address?`Адрес: ${card.address}`:''].filter(Boolean).join('\n')||'Не заполнено');return;} if(detail==='spiritual'){openTextModal('Духовный путь',card.spiritualPath);return;} + const bottom=el.dataset.bottom;if(bottom==='chat')navigate(`chat/${encodeURIComponent(card.login)}`);if(bottom==='links')navigate(makeProfileLinksRoute(card.login));if(bottom==='add')navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`); + }); + refresh().catch(e=>{status.className='status-line is-unavailable';status.textContent=`Ошибка: ${e.message||'unknown'}`;}); return screen; } diff --git a/shine-UI/js/pages/user-relation-manage-view.js b/shine-UI/js/pages/user-relation-manage-view.js new file mode 100644 index 00000000..412dc837 --- /dev/null +++ b/shine-UI/js/pages/user-relation-manage-view.js @@ -0,0 +1,51 @@ +import { renderHeader } from '../components/header.js'; +import { authService, state } from '../state.js'; +import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js'; +import { navigateBack } from '../router.js'; + +export const pageMeta = { id: 'user-relation-manage-view', title: 'Добавить' }; + +function effectiveSocial(f) { if (f.outCloseFriend) return 'close_friend'; if (f.outFriend) return 'friend'; if (f.outContact) return 'contact'; return 'none'; } + +export function render({ route }) { + const targetLogin = String(route?.params?.login || '').trim(); const selfLogin = String(state.session.login || '').trim(); + const screen = document.createElement('section'); screen.className = 'stack'; + const body = document.createElement('div'); body.className = 'stack'; const status = document.createElement('div'); status.className = 'status-line'; + screen.append(renderHeader({ title: 'Добавить', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body); + let flags, selfCard, targetCard; + + async function setKind(kind, enabled) { await authService.setUserRelation({ login: selfLogin, toLogin: targetLogin, kind, enabled, storagePwd: state.session.storagePwdInMemory }); } + async function changeSocial(next) { + const current = effectiveSocial(flags); if (current === next) return; + if (next === 'none') { if (flags.outCloseFriend) await setKind('close_friend', false); if (flags.outFriend) await setKind('friend', false); if (flags.outContact) await setKind('contact', false); } + if (next === 'contact') { if (flags.outCloseFriend) await setKind('close_friend', false); if (flags.outFriend) await setKind('friend', false); if (!flags.outContact) await setKind('contact', true); } + if (next === 'friend') { if (flags.outCloseFriend) await setKind('close_friend', false); if (!flags.outFriend) await setKind('friend', true); } + if (next === 'close_friend' && !flags.outCloseFriend) await setKind('close_friend', true); + await refresh(); + } + + async function refresh() { + status.textContent = 'Загрузка...'; body.innerHTML = ''; + [selfCard, targetCard, flags] = await Promise.all([loadUserProfileCard(selfLogin), loadUserProfileCard(targetLogin), loadRelationsForPair({ currentLogin: selfLogin, targetLogin })]); + const social = effectiveSocial(flags); + const canPrimary = selfCard.accountRole === 'primary' && targetCard.accountRole !== 'non_voting'; + const canShine = selfCard.accountRole === 'primary' && selfCard.shineStatus === 'shining' && targetCard.shineStatus !== 'not_interested'; + body.innerHTML = ` +
Связь с ${targetLogin} +
${['none','contact','friend','close_friend'].map(v => ``).join('')}
+
+ ${canPrimary ? `
Основной аккаунт
` : ''} + ${canShine ? `
Сияющий
` : ''} + `; + status.textContent = ''; + } + + body.addEventListener('click', async (e) => { + const btn = e.target.closest('button'); if (!btn) return; + const social = btn.dataset.socialKind; const confirm = btn.dataset.confirm; + try { status.textContent = 'Сохранение...'; if (social) await changeSocial(social); else if (confirm) { const enabled = confirm === 'official_account' ? !flags.outOfficialAccount : !flags.outShineConfirmed; await setKind(confirm, enabled); await refresh(); } } + catch (err) { status.className='status-line is-unavailable'; status.textContent=`Ошибка: ${err.message || 'unknown'}`; } + }); + refresh().catch(e => { status.className='status-line is-unavailable'; status.textContent=`Ошибка: ${e.message || 'unknown'}`; }); + return screen; +} diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 7323605a..90bc794c 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -107,6 +107,12 @@ export function parseRouteFromPath(pathname = '') { if (section === 'links') { return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } }; } + if (section === 'manage') { + return { pageId: 'user-relation-manage-view', params: { login: shineLogin } }; + } + if (section === 'list') { + return { pageId: 'user-profile-list-view', params: { login: shineLogin, kind: decodePart(segments[baseOffset + 1] || '') } }; + } if (section === 'channels') { const sub = decodePart(segments[baseOffset + 1] || '').toLowerCase(); if (sub === 'owned') { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 38937f09..bec842a4 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2905,6 +2905,18 @@ export class AuthService { return response.payload || {}; } + async listUserProfileRelations(login, listType, limit = 100, offset = 0) { + const response = await this.ws.request('ListUserProfileRelations', { login, listType, limit, offset }); + if (response.status !== 200) throw opError('ListUserProfileRelations', response); + return response.payload || {}; + } + + async listUserProfileChannels(login, mode, limit = 100, offset = 0) { + const response = await this.ws.request('ListUserProfileChannels', { login, mode, limit, offset }); + if (response.status !== 200) throw opError('ListUserProfileChannels', response); + return response.payload || {}; + } + async searchUsers(prefix, options = {}) { const payload = { prefix }; if (typeof options?.isServer === 'boolean') { diff --git a/shine-UI/js/services/user-connections.js b/shine-UI/js/services/user-connections.js index c6c77af1..948d7630 100644 --- a/shine-UI/js/services/user-connections.js +++ b/shine-UI/js/services/user-connections.js @@ -262,6 +262,16 @@ export async function loadUserProfileCard(login) { // Compatibility aliases for older graph/card consumers; values come only from the new profile schema. official: String(snapshot?.accountRole || '').trim().toLowerCase() === 'primary', shine: String(snapshot?.shineStatus || '').trim().toLowerCase() === 'shining', + stats: { + ownedPublicChannelsCount: Number(user.ownedPublicChannelsCount || 0), + followingChannelsCount: Number(user.followingChannelsCount || 0), + friendsCount: Number(user.friendsCount || 0), + closeFriendsCount: Number(user.closeFriendsCount || 0), + primaryReceivedCount: Number(user.primaryConfirmationsReceivedCount || 0), + primaryGivenCount: Number(user.primaryConfirmationsGivenCount || 0), + shineReceivedCount: Number(user.shineConfirmationsReceivedCount || 0), + shineGivenCount: Number(user.shineConfirmationsGivenCount || 0), + }, avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim(), diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index c34a8bf9..b3350ad8 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -10599,3 +10599,12 @@ body.chat-topbar-overlay .composer-slot { height: 22px; display: block; } + +/* Profile v18 statistics/lists */ +.profile-stats-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.profile-stat-card{display:flex;flex-direction:column;align-items:flex-start;gap:3px;text-align:left;cursor:pointer} +.profile-stat-card b{font-size:22px}.profile-stat-card span{font-size:12px;opacity:.78} +.profile-list-row{width:100%;align-items:center;gap:12px;text-align:left;cursor:pointer} +.profile-list-row-text{display:flex;flex-direction:column;gap:3px;min-width:0}.profile-list-row-text small{opacity:.68} +.profile-bottom-actions{justify-content:center;gap:12px;position:sticky;bottom:12px;z-index:3} +.profile-about:empty{display:none}