fix(desktop): paginate complete channel directory (#1690)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-09 17:07:17 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent 841282141b
commit b41cf3ffc6
5 changed files with 135 additions and 31 deletions
+33 -1
View File
@@ -613,7 +613,6 @@ pub async fn get_accessible_channel_ids(
SELECT id AS channel_id
FROM channels
WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL
LIMIT 1000
"#,
)
.bind(community_id.as_uuid())
@@ -1739,6 +1738,39 @@ mod tests {
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn accessible_channel_ids_are_not_truncated_at_one_thousand() {
let database_url =
std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = PgPool::connect(&database_url)
.await
.expect("connect to test DB");
let community_id = make_test_community(&pool).await;
let community = CommunityId::from_uuid(community_id);
let viewer = random_pubkey();
let channel_count = 1_001;
sqlx::query(
r#"
INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by)
SELECT gen_random_uuid(), $1, 'high-volume-' || n, 'stream', 'open', $2
FROM generate_series(1, $3) n
"#,
)
.bind(community_id)
.bind(&viewer)
.bind(channel_count)
.execute(&pool)
.await
.expect("insert high-volume open channels");
let channel_ids = get_accessible_channel_ids(&pool, community, &viewer)
.await
.expect("load accessible channel ids");
assert_eq!(channel_ids.len(), channel_count as usize);
}
/// A random non-admin, non-owner user cannot remove someone else's bot.
#[tokio::test]
#[ignore = "requires Postgres"]
+41 -28
View File
@@ -10,6 +10,41 @@ use crate::{
// ── Reads (pure-nostr via /query) ────────────────────────────────────────────
const DIRECTORY_PAGE_SIZE: usize = 500;
fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) {
let last = page
.last()
.expect("a full relay page always has a last event");
filter["until"] = serde_json::json!(last.created_at.as_secs());
filter["before_id"] = serde_json::json!(last.id.to_hex());
}
/// Fetch every page for a historical relay filter using the relay's composite
/// `(until, before_id)` cursor. A timestamp-only cursor can skip rows when more
/// than one page of events shares the same second.
async fn query_relay_all(
state: &AppState,
mut filter: serde_json::Value,
) -> Result<Vec<nostr::Event>, String> {
filter["limit"] = serde_json::json!(DIRECTORY_PAGE_SIZE);
let mut all = Vec::new();
loop {
let page = query_relay(state, &[filter.clone()]).await?;
let done = page.len() < DIRECTORY_PAGE_SIZE;
if !done {
advance_directory_cursor(&mut filter, &page);
}
all.extend(page);
if done {
return Ok(all);
}
}
}
#[tauri::command]
pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>, String> {
let _profile_start = std::time::Instant::now();
@@ -20,26 +55,11 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
// Step 1: find all kind:39002 (members) events that mention me, then
// pull the channel ids out of their `d` tags.
let member_events = {
let mut all = Vec::new();
let mut until: Option<u64> = None;
loop {
let mut f = serde_json::json!({"kinds": [39002], "#p": [&my_pubkey], "limit": 500});
if let Some(u) = until {
f["until"] = serde_json::json!(u);
}
let page = query_relay(&state, &[f]).await?;
let done = page.len() < 500;
if let Some(t) = page.iter().map(|e| e.created_at.as_secs()).min() {
until = Some(t.saturating_sub(1));
}
all.extend(page);
if done {
break;
}
}
all
};
let member_events = query_relay_all(
&state,
serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}),
)
.await?;
#[cfg(debug_assertions)]
let t_members = _profile_start.elapsed();
@@ -85,14 +105,7 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
// Step 3: fetch ALL open channel metadata so the channel browser can show
// discoverable channels the user hasn't joined yet. The relay's access
// control allows reading kind:39000 for open channels regardless of membership.
let open_meta_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [39000],
"limit": 5000,
})],
)
.await?;
let open_meta_events = query_relay_all(&state, serde_json::json!({"kinds": [39000]})).await?;
#[cfg(debug_assertions)]
let t_open_meta = _profile_start.elapsed();
@@ -2,10 +2,14 @@
// channels.rs under the per-file line cap.
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
/// Build a signed event for testing with the given kind, content, and tags.
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> nostr::Event {
ev_at(kind, content, tags, Timestamp::now())
}
fn ev_at(kind: u16, content: &str, tags: Vec<Vec<&str>>, created_at: Timestamp) -> nostr::Event {
let keys = Keys::generate();
let parsed: Vec<Tag> = tags
.into_iter()
@@ -13,6 +17,7 @@ fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> nostr::Event {
.collect();
EventBuilder::new(Kind::from_u16(kind), content)
.tags(parsed)
.custom_created_at(created_at)
.sign_with_keys(&keys)
.expect("sign")
}
@@ -22,6 +27,18 @@ const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
#[test]
fn directory_cursor_keeps_same_second_tiebreaker() {
let timestamp = Timestamp::from(1_700_000_000);
let event = ev_at(39000, "{}", vec![], timestamp);
let mut filter = serde_json::json!({"kinds": [39000], "limit": DIRECTORY_PAGE_SIZE});
advance_directory_cursor(&mut filter, &[event.clone()]);
assert_eq!(filter["until"], serde_json::json!(timestamp.as_secs()));
assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
}
#[test]
fn counts_unique_p_tags_per_channel() {
let e1 = ev(
@@ -54,6 +54,7 @@ import { EditRespondToDialog } from "./EditRespondToDialog";
import { useMembersSidebarActions } from "./useMembersSidebarActions";
import { useMembersSidebarModeration } from "./useMembersSidebarModeration";
const MEMBER_ADD_RESULT_LIMIT = 50;
const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2;
const MEMBER_ROW_INSET_DIVIDER_CLASS =
"after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden";
@@ -235,7 +236,10 @@ export function MembersSidebar({
channel?.channelType !== "dm";
const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, {
allowEmpty: false,
enabled: open && canAddMembers && deferredSearchQuery.length > 0,
enabled:
open &&
canAddMembers &&
deferredSearchQuery.length >= MEMBER_SEARCH_MIN_QUERY_LENGTH,
limit: MEMBER_ADD_RESULT_LIMIT,
});
const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data);
+38
View File
@@ -1906,6 +1906,44 @@ test("new DM picker pages people search beyond the first 50 results", async ({
);
});
test("member people search starts at two characters", async ({ page }) => {
const jmPubkey =
"abababababababababababababababababababababababababababababababab";
await installMockBridge(page, {
searchProfiles: [{ pubkey: jmPubkey, displayName: "jm" }],
});
await page.goto("/");
await openMembersSidebar(page, "general");
await page.getByTestId("channel-management-search-users").fill("j");
await expect(
page.getByTestId(`channel-user-search-result-${jmPubkey}`),
).toHaveCount(0);
expect(
(await readCommandPayloadLog(page)).filter(
(entry) =>
entry.command === "search_users" &&
(entry.payload as { query?: string }).query === "j",
),
).toHaveLength(0);
await page.getByTestId("channel-management-search-users").fill("jm");
await expect(
page.getByTestId(`channel-user-search-result-${jmPubkey}`),
).toContainText("jm");
const searchCalls = (await readCommandPayloadLog(page)).filter(
(entry) => entry.command === "search_users",
);
expect(searchCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ query: "jm" }),
}),
]),
);
});
test("members modal does not show direct pubkey entry", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");