Добавили счётчик подписчиков в каналах и подписки пользователя

This commit is contained in:
AidarKC
2026-08-26 11:11:46 +04:00
parent 3a5851939e
commit 01321f5200
28 changed files with 1966 additions and 44 deletions
@@ -30,6 +30,8 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_11 = 11;
public static final int SCHEMA_VERSION_12 = 12;
public static final int SCHEMA_VERSION_13 = 13;
public static final int SCHEMA_VERSION_14 = 14;
public static final int SCHEMA_VERSION_15 = 15;
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";
@@ -43,6 +45,8 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
private DatabaseInitializer() {}
@@ -154,6 +158,14 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
currentVersion = SCHEMA_VERSION_13;
}
if (currentVersion < SCHEMA_VERSION_14) {
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
currentVersion = SCHEMA_VERSION_14;
}
if (currentVersion < SCHEMA_VERSION_15) {
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
currentVersion = SCHEMA_VERSION_15;
}
}
}
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
rebuildStatsState(c);
c.commit();
return new CleanupResult(
@@ -366,6 +368,100 @@ public final class BlockchainResyncCleanupDAO {
""", blockchainName);
}
private void rebuildStatsState(Connection c) throws SQLException {
try (PreparedStatement truncate = c.prepareStatement("""
TRUNCATE TABLE user_stats_state, channel_stats_state
""")) {
truncate.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0),
COALESCE(fu.following_users_count, 0),
COALESCE(fc.following_channels_count, 0),
COALESCE(cf.close_friends_count, 0),
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
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 cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login)
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
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
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code
""")) {
ps.executeUpdate();
}
}
private int executeDelete(Connection c, String sql, String value) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, value);
@@ -0,0 +1,620 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
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 cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
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;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 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
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
CREATE TRIGGER trg_blocks_connection_state_ai
AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_connection_state_ai();
TRUNCATE TABLE user_stats_state, channel_stats_state;
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
COALESCE(fu.following_users_count, 0) AS following_users_count,
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
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 cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login);
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
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 14, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,105 @@
BEGIN;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
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 cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
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;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 15, 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;
@@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
id BIGSERIAL PRIMARY KEY,
tx_signature TEXT NOT NULL,
@@ -605,6 +611,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
ON channel_names_state (owner_login, owner_bch_name);
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
@@ -921,6 +953,132 @@ BEGIN
END;
$$;
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
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 cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
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;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi()
RETURNS TRIGGER
LANGUAGE plpgsql
@@ -999,6 +1157,8 @@ AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
@@ -1011,10 +1171,159 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login;
AND to_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
@@ -1048,10 +1357,161 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login;
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
@@ -1231,4 +1691,10 @@ AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
COMMIT;
@@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil;
import blockchain.body.CreateChannelBody;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
@@ -65,6 +67,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
channel.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion);
channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
@@ -180,4 +183,28 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private static int loadSubscribersCount(Connection c, String ownerBch, int rootNumber, int channelTypeCode) {
if (channelTypeCode != (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
return 0;
}
String sql = """
SELECT subscribers_count
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0;
return Math.max(0, rs.getInt("subscribers_count"));
}
} catch (Exception e) {
log.warn("GetChannelMessages: не удалось загрузить subscribers_count для {}#{}", ownerBch, rootNumber, e);
return 0;
}
}
}
@@ -32,6 +32,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private Integer subscribersCount;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
@@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public Integer getChannelTypeVersion() { return channelTypeVersion; }
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; }
public Integer getSubscribersCount() { return subscribersCount; }
public void setSubscribersCount(Integer subscribersCount) { this.subscribersCount = subscribersCount; }
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
@@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Re
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.CurrentUserEntry;
import java.sql.SQLException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Arrays;
public class Net_GetUser_Handler implements JsonMessageHandler {
@@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey());
resp.setClientKey(u.getClientKey());
loadUserStats(resp, u.getLogin());
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
// автоматически восстанавливаем её для существующего пользователя.
@@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
}
return new String(out);
}
private static void loadUserStats(Net_GetUser_Response resp, String login) {
resp.setOwnedPublicChannelsCount(0);
resp.setFollowingUsersCount(0);
resp.setFollowingChannelsCount(0);
resp.setCloseFriendsCount(0);
String sql = """
SELECT owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count
FROM user_stats_state
WHERE login = ?
LIMIT 1
""";
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return;
}
resp.setOwnedPublicChannelsCount(rs.getInt("owned_public_channels_count"));
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
}
} catch (Exception e) {
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
}
}
}
@@ -43,6 +43,10 @@ public class Net_GetUser_Response extends Net_Response {
private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes;
private Long serverBlockchainSizeLimitBytes;
private Integer ownedPublicChannelsCount;
private Integer followingUsersCount;
private Integer followingChannelsCount;
private Integer closeFriendsCount;
public Boolean getExists() { return exists; }
public void setExists(Boolean exists) { this.exists = exists; }
@@ -74,4 +78,16 @@ public class Net_GetUser_Response extends Net_Response {
public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; }
public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; }
public Integer getOwnedPublicChannelsCount() { return ownedPublicChannelsCount; }
public void setOwnedPublicChannelsCount(Integer ownedPublicChannelsCount) { this.ownedPublicChannelsCount = ownedPublicChannelsCount; }
public Integer getFollowingUsersCount() { return followingUsersCount; }
public void setFollowingUsersCount(Integer followingUsersCount) { this.followingUsersCount = followingUsersCount; }
public Integer getFollowingChannelsCount() { return followingChannelsCount; }
public void setFollowingChannelsCount(Integer followingChannelsCount) { this.followingChannelsCount = followingChannelsCount; }
public Integer getCloseFriendsCount() { return closeFriendsCount; }
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
}
@@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody;
import blockchain.body.TextBody;
import shine.db.DbController;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
import test.it.utils.TestConfig;
@@ -13,6 +14,10 @@ import test.it.utils.log.TestResult;
import test.it.utils.ws.WsSession;
import java.time.Duration;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
@@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth {
st1.registerTextChannelRoot(newsRootBlock, newsRootHash);
}
// CREATE_CHANNEL "Updates" — второй публичный канал того же владельца
int updatesRootBlock;
byte[] updatesRootHash;
{
var ln = st1.nextLineByType(ChainState.TYPE_TECH);
sender1.send(new CreateChannelBody(
0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"Updates",
"",
CreateChannelBody.CHANNEL_TYPE_PUBLIC,
CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT
), t);
updatesRootBlock = st1.lastBlockNumber();
updatesRootHash = st1.getHash32(updatesRootBlock);
assertNotNull(updatesRootHash);
st1.registerTextChannelRoot(updatesRootBlock, updatesRootHash);
}
// POST #0 в канал "News"
int newsPost0Block;
byte[] newsPost0Hash;
@@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth {
bch1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) FRIEND взаимно (на HEADER)
// 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1),
"U2 должен иметь две отдельные записи подписки на каналы U1");
assertEquals(2, countFollowingChannels(u2),
"following_channels_count должен учитывать два разных канала одного владельца");
assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash),
"У канала News должен быть 1 подписчик");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"У канала Updates должен быть 1 подписчик");
// 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t);
@@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth {
bch1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t);
// 4) CONTACT несколько
// 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t);
@@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth {
bch3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t);
// 5) U1 убирает U2 из контактов (UNCONTACT)
// 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1),
"После отписки от одного канала должна остаться одна запись подписки");
assertEquals(1, countFollowingChannels(u2),
"following_channels_count должен уменьшиться ровно на 1");
assertEquals(0, countSubscribers(bch1, newsRootBlock, newsRootHash),
"После отписки от News у канала должен остаться 0 подписчиков");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"Подписка на Updates должна остаться");
// 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t);
@@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth {
toBlockHash32
), timeout);
}
private static int countConnectionsByOwner(String login, String ownerLogin) {
return queryInt("""
SELECT COUNT(*)
FROM connections_state
WHERE login = ?
AND rel_type = 30
AND to_login = ?
""", login, ownerLogin);
}
private static int countFollowingChannels(String login) {
return queryInt("""
SELECT following_channels_count
FROM user_stats_state
WHERE login = ?
""", login);
}
private static int countSubscribers(String ownerBlockchainName, int rootBlockNumber, byte[] rootBlockHash) {
return queryInt("""
SELECT COALESCE(subscribers_count, 0)
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
AND channel_root_block_hash = ?
""", ownerBlockchainName, rootBlockNumber, rootBlockHash);
}
private static int queryInt(String sql, Object... params) {
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
Object p = params[i];
if (p instanceof String s) {
ps.setString(i + 1, s);
} else if (p instanceof Integer n) {
ps.setInt(i + 1, n);
} else if (p instanceof byte[] bytes) {
ps.setBytes(i + 1, bytes);
} else {
throw new IllegalArgumentException("Unsupported SQL param type: " + (p == null ? "null" : p.getClass()));
}
}
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("Query returned no rows: " + sql);
}
return rs.getInt(1);
}
} catch (SQLException e) {
throw new RuntimeException("DB query failed: " + sql, e);
}
}
}
+9 -1
View File
@@ -57,7 +57,11 @@
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,
"serverBlockchainSizeLimitBytes": 100000
"serverBlockchainSizeLimitBytes": 100000,
"ownedPublicChannelsCount": 3,
"followingUsersCount": 18,
"followingChannelsCount": 27,
"closeFriendsCount": 4
}
}
```
@@ -68,6 +72,10 @@
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь;
- `followingUsersCount` — количество пользователей, на которых подписан пользователь;
- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь;
- `closeFriendsCount` — количество близких друзей пользователя.
### Успешный ответ: пользователя нет
+1
View File
@@ -158,6 +158,7 @@
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"subscribersCount": 128,
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"metaEvents": [
+13
View File
@@ -1,5 +1,18 @@
# История изменений документации блокчейна
## 2026-08-25 19:45:18 +0400
- Базовый коммит-ориентир: `3a58519`.
- Добавлены runtime-агрегаты статистики:
- `user_stats_state` хранит `owned_public_channels_count`, `following_users_count`, `following_channels_count`, `close_friends_count`;
- `channel_stats_state` хранит `subscribers_count` для публичных каналов.
- Обновление статистики выполняется атомарно вместе с записью блока в той же SQL-транзакции `AddBlock`.
- После полного resync статистика пересобирается из текущих `connections_state` и `channel_names_state`, чтобы старые данные продолжали работать.
## 2026-08-25 19:59:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Триггер `channel_names_state` для статистики сделан устойчивым к старым данным, где канал уже есть, а владельца ещё нет в `solana_user_pda_current`.
- Добавлена совместимая миграция `v15`, чтобы существующие базы перестали падать на старых каналах при старте сервера.
## 2026-08-12 13:00:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Канонический текстовый формат служебных тегов сокращён:
+2
View File
@@ -42,4 +42,6 @@
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync.
- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

+3
View File
@@ -93,6 +93,7 @@ import * as chatView from './pages/chat-view.js?v=202608221218';
import * as userProfileView from './pages/user-profile-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';
import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
@@ -155,6 +156,7 @@ const routes = {
user: userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-about-view': channelAboutView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView,
'add-personal-public-chat-view': addPersonalPublicChatView,
@@ -212,6 +214,7 @@ const GUEST_ALLOWED_PAGES = new Set([
'network-view',
'channels-list',
'channel-view',
'channel-about-view',
'channel-thread-view',
'user',
'contact-search-view',
+10
View File
@@ -0,0 +1,10 @@
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg';
export function createShineConnectionsLogo({ className = '' } = {}) {
const img = document.createElement('img');
img.src = SHINE_CONNECTIONS_LOGO_SRC;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.className = String(className || '').trim();
return img;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
@@ -8,7 +9,7 @@ import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
];
+182
View File
@@ -0,0 +1,182 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { navigateBack } from '../router.js';
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
import { showToast } from '../services/channels-ux.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channel-about-view', title: 'О канале' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function normalizeHash(hash) {
const normalized = String(hash || '').trim().toLowerCase();
return normalized || '0';
}
function toSafeInt(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) {
const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase();
const rootNo = Number(channelRootBlockNumber);
const rootHash = normalizeHash(channelRootBlockHash);
const rows = Object.values(state.channelsIndex || {});
return rows.find((row) => (
String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
&& Number(row?.channel?.channelRoot?.blockNumber) === rootNo
&& normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash
)) || null;
}
function buildChannelLink(route) {
if (!route) return '';
const url = new URL(window.location.href);
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
url.hash = '';
return url.toString();
}
function statsText(value) {
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
}
export function render({ navigate, route, chrome }) {
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
const channelRoute = makeShineChannelRootRoute({
ownerBlockchainName,
channelRootBlockNumber,
channelRootBlockHash,
});
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = renderHeader({
title: 'О канале',
leftAction: {
label: '←',
onClick: () => {
if (window.history.length > 1) {
navigateBack();
return;
}
if (channelRoute) navigate(channelRoute);
},
ariaLabel: 'Назад',
title: 'Назад',
},
});
chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack channel-about-card';
card.innerHTML = `
<div class="stack" id="channel-about-content">
<div class="meta-muted">Загрузка данных канала</div>
</div>
`;
const footer = document.createElement('div');
footer.className = 'meta-muted screen-footer';
footer.textContent = 'О канале (channel-about-view)';
screen.append(card, footer);
const renderContent = (channel) => {
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim();
const description = String(channel?.channelDescription || channel?.description || '').trim();
const subscribersCount = Number(channel?.subscribersCount || 0);
const aboutRoute = makeShineChannelRootRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
});
const channelLinkRoute = makeShineChannelShortRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelName: channel?.channelName || '',
});
const channelLink = buildChannelLink(channelLinkRoute);
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
const content = card.querySelector('#channel-about-content');
if (!content) return;
content.innerHTML = `
<div class="channel-profile-modal-head">
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
</div>
<div class="channel-meta-details-grid">
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
</div>
`;
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (!channelLinkRoute) return;
navigate(channelLinkRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
if (!channelLink) return;
try {
await navigator.clipboard.writeText(channelLink);
showToast('Ссылка скопирована');
} catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
}
});
};
const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash);
if (cached?.channel) {
renderContent({
...cached.channel,
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
});
return screen;
}
void (async () => {
try {
const payload = await authService.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber,
channelRootBlockHash,
}, 1, 'asc', String(state.session.login || '').trim());
renderContent(payload?.channel || {});
} catch (error) {
const content = card.querySelector('#channel-about-content');
if (content) {
content.innerHTML = `
<div class="meta-muted">Не удалось загрузить данные канала.</div>
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
`;
}
}
})();
return screen;
}
+6 -6
View File
@@ -30,6 +30,7 @@ import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
makeShineChannelAboutRoute,
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -2550,13 +2551,12 @@ export function render({ navigate, route, chrome }) {
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
onEdit: () => openEditChannelModal({
channel: apiData.channel,
onSave: onEditChannelMeta,
}),
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
if (aboutRoute) navigate(aboutRoute);
};
}
if (channelEntrypointButton) {
+3 -3
View File
@@ -12,6 +12,7 @@ import {
writeChannelNotificationsState,
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -64,10 +65,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
return makeShineChannelRoute({
ownerLogin,
return makeShineChannelShortRoute({
ownerBlockchainName: ownerBch,
channelName: channelName || fallbackId,
channelName: channelName || fallbackId || ownerLogin,
});
}
+31 -12
View File
@@ -1,6 +1,7 @@
import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
@@ -30,11 +31,12 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function createChatHeaderParts(login) {
function createChatHeaderParts(login, navigate) {
const cleanLogin = String(login || '').trim() || 'unknown';
const avatarSlot = document.createElement('span');
@@ -47,6 +49,13 @@ function createChatHeaderParts(login) {
});
avatarSlot.append(initialAvatar);
const avatarButton = document.createElement('button');
avatarButton.type = 'button';
avatarButton.className = 'chat-header-avatar-btn';
avatarButton.title = `Профиль ${cleanLogin}`;
avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`);
avatarButton.append(avatarSlot);
const loginEl = document.createElement('span');
loginEl.className = 'chat-header-login';
loginEl.setAttribute('role', 'heading');
@@ -75,7 +84,23 @@ function createChatHeaderParts(login) {
})
.catch(() => {});
return { centerNode: loginEl, avatarSlot };
const connectionsButton = document.createElement('button');
connectionsButton.type = 'button';
connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn';
connectionsButton.title = `Связи ${cleanLogin}`;
connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`);
connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' }));
connectionsButton.addEventListener('click', () => {
if (!cleanLogin || cleanLogin === 'unknown') return;
navigate(makeProfileLinksRoute(cleanLogin));
});
avatarButton.addEventListener('click', () => {
if (!cleanLogin || cleanLogin === 'unknown') return;
navigate(makeProfileRoute(cleanLogin));
});
return { centerNode: loginEl, avatarButton, connectionsButton };
}
function truncatePreviewText(value, maxLen = 72) {
@@ -293,7 +318,6 @@ function openChatActionsMenu({
anchorY = 0,
onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
@@ -305,8 +329,7 @@ function openChatActionsMenu({
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
@@ -358,10 +381,6 @@ function openChatActionsMenu({
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
close();
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -993,7 +1012,7 @@ export function render({ navigate, route, chrome }) {
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId);
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
@@ -1016,7 +1035,6 @@ export function render({ navigate, route, chrome }) {
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -1064,7 +1082,8 @@ export function render({ navigate, route, chrome }) {
},
],
});
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
const chatHeaderLeft = chatHeader.querySelector('.header-left');
chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton);
chrome?.setTopbar(chatHeader);
if (!isKnownContact) {
+5 -5
View File
@@ -8,6 +8,7 @@ import {
} from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -167,15 +168,11 @@ function compareChatRows(a, b) {
export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand">
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name"></span>
</div>
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
</div>
<h1 class="dm-head-title">Чаты</h1>
<div class="dm-head-menu-wrap">
@@ -191,6 +188,9 @@ export function render({ navigate, chrome }) {
</div>
</div>
`;
head.querySelector('.dm-head-logo-wrap')?.append(
createShineConnectionsLogo({ className: 'dm-head-logo' }),
);
const menuButton = head.querySelector('.dm-head-menu-btn');
menuButton?.append(createOverflowDots());
+39 -2
View File
@@ -1,5 +1,5 @@
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { authService, state } from '../state.js';
import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
@@ -223,6 +223,12 @@ export function render({ navigate, chrome }) {
let currentToggles = [];
let currentGender = 'unknown';
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
let currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
function syncIdentity() {
if (!identityEl) return;
@@ -269,6 +275,21 @@ export function render({ navigate, chrome }) {
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
}
function renderStats() {
const stats = [
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
];
stats.forEach((stat) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
listWrap.append(row);
});
}
officialBtn?.classList.add('profile-badge-trigger');
shineBtn?.classList.add('profile-badge-trigger');
officialBtn?.addEventListener('click', () => {
@@ -286,6 +307,7 @@ export function render({ navigate, chrome }) {
function renderFields(fields) {
listWrap.innerHTML = '';
renderStats();
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
@@ -317,6 +339,12 @@ export function render({ navigate, chrome }) {
];
currentGender = 'unknown';
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
@@ -325,11 +353,20 @@ export function render({ navigate, chrome }) {
}
try {
const snapshot = await loadProfileSnapshot(login);
const [snapshot, user] = await Promise.all([
loadProfileSnapshot(login),
authService.getUser(login).catch(() => ({})),
]);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
currentGender = snapshot.gender || 'unknown';
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingUsersCount: Number(user?.followingUsersCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
};
syncIdentity();
updateAvatarUi();
updateTogglesUi();
+45 -1
View File
@@ -26,6 +26,7 @@ const PRETTY_PATHS = new Map([
['add-channel-view', 'channels/new'],
['add-personal-public-chat-view', 'channels/new-public-chat'],
['channel-view', 'channel'],
['channel-about-view', 'channel/about'],
['channel-thread-view', 'thread'],
['network-view', 'network'],
['notifications-view', 'notifications'],
@@ -52,6 +53,10 @@ const PRETTY_PATHS = new Map([
['remote-addblock-session-view', 'remote-addblock-session'],
]);
function looksLikeBlockchainName(value) {
return /^.+-\d+$/.test(String(value || '').trim());
}
export const PRE_AUTH_PAGES = [
'start-view',
'entry-settings-view',
@@ -162,6 +167,34 @@ export function parseRouteFromPath(pathname = '') {
}
}
if (segments.length >= 2 && looksLikeBlockchainName(segments[0])) {
const ownerBlockchainName = decodePart(segments[0]);
const channelName = decodePart(segments[1] || '');
const sub = decodePart(segments[2] || '').toLowerCase();
if (ownerBlockchainName && channelName) {
if (sub === 'about') {
return {
pageId: 'channel-about-view',
params: {
ownerBlockchainName,
channelRootBlockNumber: '',
channelRootBlockHash: '',
channelId: '',
channelName,
},
};
}
return {
pageId: 'channel-view',
params: {
ownerBlockchainName,
channelName,
channelId: '',
},
};
}
}
if (pageId === 'chat' || pageId === 'chat-view') {
return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
@@ -225,6 +258,17 @@ export function parseRouteFromPath(pathname = '') {
}
if (pageId === 'channel') {
if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') {
return {
pageId: 'channel-about-view',
params: {
ownerBlockchainName: decodePart(segments[1]),
channelRootBlockNumber: segments[2] || '',
channelRootBlockHash: segments[3] || '',
channelId: '',
},
};
}
if (segments.length >= 4) {
return {
pageId: 'channel-view',
@@ -408,7 +452,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'solana-users-init-view'
) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'user') return 'messages-list';
return 'profile-view';
}
+2 -2
View File
@@ -153,7 +153,7 @@ function getCallTitleText(mode) {
return 'Видеозвонок';
}
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
return 'Звонок с поддержкой видео';
return 'Видеозвонок';
}
return 'Звонок';
}
@@ -164,7 +164,7 @@ function getIncomingCallStatusText(peerLogin, mode) {
return `Входящий видеозвонок от ${name}`;
}
if (isVideoCallMode(mode)) {
return `Вам звонит ${name} (звонок с поддержкой видео)`;
return `Входящий видеозвонок от ${name}`;
}
return `Вам звонит ${name}`;
}
+20
View File
@@ -51,6 +51,26 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineChannelRootRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
const ownerBch = String(ownerBlockchainName || '').trim();
const rootNo = String(channelRootBlockNumber || '').trim();
const rootHash = String(channelRootBlockHash || '').trim();
if (!ownerBch || !rootNo || !rootHash) return '';
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(rootNo)}/${encodeRoutePart(rootHash)}`;
}
export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash });
return base ? `${base}/about` : '';
}
export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) {
const ownerBch = String(ownerBlockchainName || '').trim();
const chName = String(channelName || '').trim();
if (!ownerBch || !chName) return '';
return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
+75 -6
View File
@@ -5927,6 +5927,14 @@ textarea.input {
transform: translateY(-40%);
}
.channels-screen--channel-about .screen-footer {
margin-top: auto;
padding: 8px 4px 0;
text-align: center;
font-size: 12px;
letter-spacing: 0.02em;
}
.entrypoint-history-list {
display: grid;
gap: 10px;
@@ -6184,12 +6192,13 @@ textarea.input {
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
}
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dm-head-hex {
width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
font-weight: 700; font-size: 15px; color: #1a1205;
background: linear-gradient(150deg, #F0B82E, #D49F22);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
.dm-head-logo-wrap { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: 0 0 auto; }
.dm-head-logo {
width: 36px;
height: 36px;
object-fit: contain;
display: block;
filter: drop-shadow(0 0 7px rgba(71, 196, 255, 0.38));
}
.dm-head-id { min-width: 0; display: grid; }
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -9100,6 +9109,66 @@ body.chat-topbar-overlay .app-shell.keyboard-open .scroll-to-bottom-btn {
gap: 0;
}
.chat-header-connections-btn,
.chat-header-avatar-btn {
padding: 0;
min-width: 0;
min-height: 0;
border: 0;
background: transparent;
box-shadow: none;
transform: none;
}
.chat-header-connections-btn:hover,
.chat-header-connections-btn:active,
.chat-header-connections-btn:focus,
.chat-header-connections-btn:focus-visible,
.chat-header-avatar-btn:hover,
.chat-header-avatar-btn:active,
.chat-header-avatar-btn:focus,
.chat-header-avatar-btn:focus-visible {
border: 0;
background: transparent;
box-shadow: none;
transform: none;
}
.chat-header-connections-btn {
width: 38px;
height: 38px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(8, 17, 31, 0.65);
border: 1px solid rgba(97, 198, 255, 0.26);
box-shadow: 0 0 0 1px rgba(97, 198, 255, 0.08), 0 0 12px rgba(64, 172, 255, 0.18);
}
.chat-header-connections-btn:hover,
.chat-header-connections-btn:focus-visible {
border-color: rgba(111, 208, 255, 0.38);
box-shadow: 0 0 0 1px rgba(111, 208, 255, 0.16), 0 0 16px rgba(64, 172, 255, 0.28);
}
.chat-header-connections-logo {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
.chat-header-avatar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.chat-header-avatar-btn .chat-header-avatar-slot {
display: inline-flex;
}
/* Пузырям сообщений не нужна отдельная обводка: направление читается по фону и геометрии. */
.dm-chat-screen .bubble,
.dm-chat-screen .bubble.in,