mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
841282141b
commit
b41cf3ffc6
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user