diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 22476d6bc2..ebadd06cf6 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -120,11 +120,27 @@ fn collect_stored_events(rows: Vec) -> Result> { /// [`FEED_CONVERSATION_EVENT_CAP`] events per conversation, and whole /// conversations are emitted newest-activity-first so the final `LIMIT` /// truncates at a conversation boundary instead of mid-window. +/// +/// ## Pagination (`until`) +/// +/// `until` is an **inclusive** upper bound (nostr convention) on +/// `conv_latest` — the conversation's latest activity — NOT on individual +/// event timestamps. Bounding per-event would split a conversation across +/// pages under a new key; bounding the conversation keeps each page a set +/// of whole conversations strictly older than (or tied with) the cursor. +/// The candidate scan itself stays unbounded-by-`until` on purpose: the +/// window ranks a conversation's newest events globally, so re-scanning +/// keeps `conv_latest` (and therefore page membership) stable across pages. +/// Callers page with `until` = the oldest `conv_latest` in hand and dedupe +/// the overlap row; pagination depth is bounded by +/// [`FEED_WINDOW_SCAN_CAP`] — conversations older than the newest 2000 +/// mention-events are not reachable through this feed. fn build_mentions_query( community: CommunityId, pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); @@ -172,9 +188,14 @@ fn build_mentions_query( FROM keyed k \ ) \ SELECT {EVENT_COLS_UNALIASED} FROM ranked \ - WHERE conv_rank <= {FEED_CONVERSATION_EVENT_CAP} \ - ORDER BY conv_latest DESC, conv_key, created_at DESC LIMIT " + WHERE conv_rank <= {FEED_CONVERSATION_EVENT_CAP}" )); + if let Some(u) = until { + // Conversation-level bound: pages cut on conv_latest so a + // conversation is never split across pages (see doc comment). + qb.push(" AND conv_latest <= ").push_bind(u); + } + qb.push(" ORDER BY conv_latest DESC, conv_key, created_at DESC LIMIT "); qb.push_bind(limit); qb } @@ -193,6 +214,7 @@ pub async fn query_mentions( pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { let mut conn = pool.acquire().await?; @@ -202,6 +224,7 @@ pub async fn query_mentions( pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -216,6 +239,7 @@ pub(crate) async fn query_mentions_on( pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { let mut qb = build_mentions_query( @@ -223,6 +247,7 @@ pub(crate) async fn query_mentions_on( pubkey_bytes, accessible_channel_ids, since, + until, limit, ); let rows = qb.build().fetch_all(&mut *conn).await?; @@ -234,6 +259,7 @@ fn build_needs_action_query( pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); @@ -256,6 +282,10 @@ fn build_needs_action_query( if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } + if let Some(u) = until { + // Flat query: the inclusive nostr `until` bounds event time directly. + qb.push(" AND m.event_created_at <= ").push_bind(u); + } qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); qb @@ -277,6 +307,7 @@ pub async fn query_needs_action( pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { let mut conn = pool.acquire().await?; @@ -286,6 +317,7 @@ pub async fn query_needs_action( pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -298,6 +330,7 @@ pub(crate) async fn query_needs_action_on( pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { let mut qb = build_needs_action_query( @@ -305,6 +338,7 @@ pub(crate) async fn query_needs_action_on( pubkey_bytes, accessible_channel_ids, since, + until, limit, ); let rows = qb.build().fetch_all(&mut *conn).await?; @@ -315,6 +349,7 @@ fn build_activity_query( community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); @@ -331,6 +366,10 @@ fn build_activity_query( if let Some(s) = since { qb.push(" AND created_at >= ").push_bind(s); } + if let Some(u) = until { + // Flat query: the inclusive nostr `until` bounds event time directly. + qb.push(" AND created_at <= ").push_bind(u); + } qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); qb } @@ -346,10 +385,19 @@ pub async fn query_activity( community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { let mut conn = pool.acquire().await?; - query_activity_on(&mut conn, community, accessible_channel_ids, since, limit).await + query_activity_on( + &mut conn, + community, + accessible_channel_ids, + since, + until, + limit, + ) + .await } /// [`query_activity`] on a specific session — see [`query_mentions_on`]. @@ -358,9 +406,10 @@ pub(crate) async fn query_activity_on( community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { - let mut qb = build_activity_query(community, accessible_channel_ids, since, limit); + let mut qb = build_activity_query(community, accessible_channel_ids, since, until, limit); let rows = qb.build().fetch_all(&mut *conn).await?; collect_stored_events(rows) } @@ -531,6 +580,7 @@ mod tests { &mentioned_bytes, &[channel_a, channel_b], None, + None, 10, ) .await @@ -579,6 +629,7 @@ mod tests { &actor_bytes, &[channel_a, channel_b], None, + None, 10, ) .await @@ -637,7 +688,7 @@ mod tests { ) .await; - let global_only = query_activity(&pool, community_a, &[], None, 10) + let global_only = query_activity(&pool, community_a, &[], None, None, 10) .await .expect("query activity global only"); assert!(global_only.iter().any(|row| row.event.id == a_global.id)); @@ -648,7 +699,7 @@ mod tests { assert!(global_only.iter().all(|row| row.event.id != b_global.id)); assert!(global_only.iter().all(|row| row.event.id != b_channel.id)); - let visible = query_activity(&pool, community_a, &[channel_a, channel_b], None, 10) + let visible = query_activity(&pool, community_a, &[channel_a, channel_b], None, None, 10) .await .expect("query visible activity"); assert!(visible.iter().any(|row| row.event.id == a_global.id)); @@ -899,7 +950,7 @@ mod tests { #[test] fn empty_channel_list_means_global_only() { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); - let mut qb = build_activity_query(community, &[], None, 10); + let mut qb = build_activity_query(community, &[], None, None, 10); let query = qb.build(); let sql_str = sqlx::Execute::sql(query); let sql = sql_str.as_str(); @@ -922,7 +973,7 @@ mod tests { fn non_empty_channel_list_includes_global_and_accessible_channels() { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); let channel_id = Uuid::new_v4(); - let mut qb = build_activity_query(community, &[channel_id], None, 10); + let mut qb = build_activity_query(community, &[channel_id], None, None, 10); let query = qb.build(); let sql_str = sqlx::Execute::sql(query); let sql = sql_str.as_str(); @@ -938,7 +989,7 @@ mod tests { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); let pubkey = vec![0x42; 32]; let channel_id = Uuid::new_v4(); - let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, None, 10); let query = qb.build(); let sql_str = sqlx::Execute::sql(query); let sql = sql_str.as_str(); @@ -968,7 +1019,7 @@ mod tests { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); let pubkey = vec![0x42; 32]; let channel_id = Uuid::new_v4(); - let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, None, 10); let query = qb.build(); let sql_str = sqlx::Execute::sql(query); let sql = sql_str.as_str(); @@ -999,6 +1050,31 @@ mod tests { ); } + #[test] + fn mentions_query_until_bounds_conversation_latest_not_event_time() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + let until = chrono::Utc::now(); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, Some(until), 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("AND conv_latest <= "), + "until must bound the conversation's latest activity: {sql}" + ); + // The cursor must not leak into the candidate scan: bounding the scan + // would change which events rank into a conversation's newest-N and + // make page membership unstable across pages. + let candidates_cte = sql.split("), keyed AS (").next().expect("candidate CTE"); + assert!( + !candidates_cte.contains("<="), + "candidate scan must stay unbounded by the pagination cursor: {sql}" + ); + } + /// The regression this windowing exists to prevent: one high-volume /// conversation must not starve every other conversation out of the /// mentions window. @@ -1064,9 +1140,17 @@ mod tests { .await; } - let rows = query_mentions(&pool, community, &mentioned_bytes, &[channel], None, 20) - .await - .expect("query windowed mentions"); + let rows = query_mentions( + &pool, + community, + &mentioned_bytes, + &[channel], + None, + None, + 20, + ) + .await + .expect("query windowed mentions"); assert!(rows.len() <= 20, "limit must hold: got {} rows", rows.len()); for standalone in &standalone_ids { @@ -1091,12 +1175,93 @@ mod tests { ); } + /// Paging contract: `until` = the previous page's oldest conversation + /// activity must return the next-older conversations, whole, with the + /// boundary conversation as the only overlap (inclusive cursor — clients + /// dedupe by conversation key). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_mentions_pages_by_conversation_cursor() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = insert_test_channel(&pool, community).await; + let mentioned_pubkey = "05".repeat(32); + let mentioned_bytes = hex::decode(&mentioned_pubkey).expect("hex pubkey"); + + let base = chrono::Utc::now().timestamp() - 10_000; + + // 9 standalone conversations at distinct times, oldest first. + let mut ids_oldest_first = Vec::new(); + for n in 0..9 { + let event = store_feed_event_at( + &pool, + community, + KIND_STREAM_MESSAGE, + &format!("paged {n}"), + Some(channel), + vec![Tag::parse(["p", mentioned_pubkey.as_str()]).unwrap()], + base + n * 100, + ) + .await; + ids_oldest_first.push(event.id); + } + + // Page 1: newest 3 conversations (limit counts events; 1 event each). + let page1 = query_mentions( + &pool, + community, + &mentioned_bytes, + &[channel], + None, + None, + 3, + ) + .await + .expect("page 1"); + let page1_ids: Vec<_> = page1.iter().map(|row| row.event.id).collect(); + assert_eq!( + page1_ids, + vec![ + ids_oldest_first[8], + ids_oldest_first[7], + ids_oldest_first[6] + ], + "page 1 must be the newest three conversations, newest first" + ); + + // Page 2: cursor = page 1's oldest activity. Inclusive bound, so the + // boundary conversation leads and the next-older ones follow. + let cursor = page1.last().expect("page 1 nonempty").event.created_at; + let cursor_ts = chrono::DateTime::from_timestamp(cursor.as_secs() as i64, 0).unwrap(); + let page2 = query_mentions( + &pool, + community, + &mentioned_bytes, + &[channel], + None, + Some(cursor_ts), + 3, + ) + .await + .expect("page 2"); + let page2_ids: Vec<_> = page2.iter().map(|row| row.event.id).collect(); + assert_eq!( + page2_ids, + vec![ + ids_oldest_first[6], + ids_oldest_first[5], + ids_oldest_first[4] + ], + "page 2 must start at the inclusive cursor and continue older" + ); + } + #[test] fn needs_action_query_is_tenant_scoped_and_joins_mentions_by_composite_key() { let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); let pubkey = vec![0x42; 32]; let channel_id = Uuid::new_v4(); - let mut qb = build_needs_action_query(community, &pubkey, &[channel_id], None, 10); + let mut qb = build_needs_action_query(community, &pubkey, &[channel_id], None, None, 10); let query = qb.build(); let sql_str = sqlx::Execute::sql(query); let sql = sql_str.as_str(); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 330525d310..16e30702fb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -3375,6 +3375,7 @@ impl Db { pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { feed::query_mentions( @@ -3383,6 +3384,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3395,6 +3397,7 @@ impl Db { /// parameter admits community-global rows alongside channel rows, so no /// single channel's fence floor can prove completeness — the covered arm /// is structurally unavailable, not merely unchosen. + #[allow(clippy::too_many_arguments)] #[datastore_span(name = "query_feed_mentions_routed", system = "postgresql")] pub async fn query_feed_mentions_routed( &self, @@ -3403,6 +3406,7 @@ impl Db { pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { match self.route_read(path, RoutePredicate::Bounded).await { @@ -3413,6 +3417,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3430,6 +3435,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3443,6 +3449,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3458,6 +3465,7 @@ impl Db { pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { feed::query_needs_action( @@ -3466,6 +3474,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3474,6 +3483,7 @@ impl Db { /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm /// is structurally unavailable to feed queries. + #[allow(clippy::too_many_arguments)] #[datastore_span(name = "query_feed_needs_action_routed", system = "postgresql")] pub async fn query_feed_needs_action_routed( &self, @@ -3482,6 +3492,7 @@ impl Db { pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { match self.route_read(path, RoutePredicate::Bounded).await { @@ -3492,6 +3503,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3509,6 +3521,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3522,6 +3535,7 @@ impl Db { pubkey_bytes, accessible_channel_ids, since, + until, limit, ) .await @@ -3536,9 +3550,18 @@ impl Db { community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await + feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + until, + limit, + ) + .await } /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; @@ -3551,6 +3574,7 @@ impl Db { community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, + until: Option>, limit: i64, ) -> Result> { match self.route_read(path, RoutePredicate::Bounded).await { @@ -3560,6 +3584,7 @@ impl Db { community, accessible_channel_ids, since, + until, limit, ) .await @@ -3576,6 +3601,7 @@ impl Db { community, accessible_channel_ids, since, + until, limit, ) .await @@ -3583,8 +3609,15 @@ impl Db { } } RouteDecision::Writer => { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) - .await + feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + until, + limit, + ) + .await } } } @@ -8285,13 +8318,21 @@ mod tests { // accessible — so only the community predicate can exclude B. let both = [chan_a, chan_b]; let rows = db - .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, None, 50) .await .expect("routed mentions"); assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); let rows = db - .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .query_feed_needs_action_routed( + "sep_feed", + cid_a, + &mentioned_bytes, + &both, + None, + None, + 50, + ) .await .expect("routed needs action"); assert_a_only( @@ -8301,7 +8342,7 @@ mod tests { ); let rows = db - .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) + .query_feed_activity_routed("sep_feed", cid_a, &both, None, None, 50) .await .expect("routed activity"); assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0856c85cf3..e5c2b6123a 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1070,6 +1070,13 @@ async fn query_events_authed( let since = filter .since .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); + // Pagination cursor: nostr-standard inclusive `until`. For mentions + // it bounds the conversation's latest activity (whole-conversation + // pages, see build_mentions_query); for the flat feeds it bounds + // event time. + let until = filter + .until + .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); let mut seen_types = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new(); @@ -1096,6 +1103,7 @@ async fn query_events_authed( &pubkey_bytes, &accessible_channels, since, + until, remaining, ) .await @@ -1108,6 +1116,7 @@ async fn query_events_authed( &pubkey_bytes, &accessible_channels, since, + until, remaining, ) .await @@ -1119,6 +1128,7 @@ async fn query_events_authed( tenant.community(), &accessible_channels, since, + until, remaining, ) .await diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d1f57c2298..3a80991936 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ "**/integration.spec.ts", "**/dm-double-notification.spec.ts", "**/inbox-windowing-screenshots.spec.ts", + "**/inbox-pagination-screenshots.spec.ts", "**/profile.spec.ts", "**/sidebar.spec.ts", "**/sidebar-relay-card.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 38fd270afe..cab7888c00 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -46,6 +46,7 @@ const TIMELINE_KINDS: [u32; 11] = [ #[tauri::command] pub async fn get_feed( since: Option, + until: Option, limit: Option, types: Option, state: State<'_, AppState>, @@ -68,14 +69,10 @@ pub async fn get_feed( keys.public_key().to_hex() }; - // Mentions: messages that reference me via #p. - // - // `feed_types` routes this filter through the relay's feed path, whose - // mentions query windows **per conversation** (newest conversations first, - // a few newest events each) instead of a flat newest-N-events cut. The - // flat cut let one chatty thread/DM fill the whole window and starve - // every other conversation out of the Inbox. The kinds below still bound - // the generic-query fallback for older relays that ignore `feed_types`. + // Mentions: messages that reference me via #p. `feed_types` routes this + // through the relay's per-conversation-windowed feed path (newest + // conversations first, a few newest events each) so one chatty thread + // can't starve the Inbox; kinds still bound the non-feed_types fallback. let mut mention_filter = serde_json::json!({ "feed_types": ["mentions"], "kinds": [ @@ -107,6 +104,13 @@ pub async fn get_feed( if let Some(s) = since { approval_filter["since"] = serde_json::json!(s); } + // Inclusive pagination cursor — the relay feed path bounds each + // conversation's latest activity with it (see buzz-db feed.rs), so a page + // is whole conversations; the client dedupes the boundary overlap. + if let Some(u) = until { + mention_filter["until"] = serde_json::json!(u); + approval_filter["until"] = serde_json::json!(u); + } let mention_events = if want_mentions { query_relay(&state, &[mention_filter]) diff --git a/desktop/src/features/home/hooks.ts b/desktop/src/features/home/hooks.ts index ce4a0a3056..bea3e1e42e 100644 --- a/desktop/src/features/home/hooks.ts +++ b/desktop/src/features/home/hooks.ts @@ -1,6 +1,10 @@ +import * as React from "react"; + import { useQuery } from "@tanstack/react-query"; +import { getOldestConversationActivity } from "@/features/home/lib/inbox"; import { getHomeFeed } from "@/shared/api/tauri"; +import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { useRelayConnection } from "@/shared/api/useRelayConnection"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; @@ -37,3 +41,113 @@ export function useHomeFeedQuery() { ...homeFeedFocusRefetchPolicy, }); } + +/** Page size for older inbox pages — matches the first page's window. */ +export const HOME_FEED_OLDER_PAGE_LIMIT = 50; + +type HomeFeedPagination = { + /** The first page merged with every older page fetched so far. */ + feed: HomeFeedResponse | undefined; + /** Fetch the next page of older conversations. No-op while one is in flight. */ + fetchOlder: () => void; + isFetchingOlder: boolean; + /** False once a fetch returns nothing new — the feed window is exhausted. */ + hasOlder: boolean; +}; + +/** + * Cursor pagination for the inbox, layered over the polling first page. + * + * The first page (from [`useHomeFeedQuery`]) stays live — re-polled every + * 30s — while older pages are fetched once on demand and held locally. + * The cursor is the oldest conversation's latest activity across the mention + * events in hand (see `getOldestConversationActivity`); the relay returns the + * next `HOME_FEED_OLDER_PAGE_LIMIT`-event window of whole conversations at or + * older than it. The inclusive boundary conversation and any conversation + * that later re-enters the live first page arrive as duplicate event ids, so + * the merge dedupes by id; `buildInboxItems` then groups by conversation as + * usual. + * + * Older pages request `types: "mentions"` only: the needs-action query is a + * fixed-window side feed and `activity`/`agent_activity` are not served by + * the native `get_feed` — mentions are what the inbox list is made of. + * + * Pagination is gated on the channel list being loaded: the cursor derivation + * mirrors the relay's DM conversation key (`dm:`), which requires + * channel types. + */ +export function useHomeFeedPagination( + baseFeed: HomeFeedResponse | undefined, + channels: Channel[] | undefined, +): HomeFeedPagination { + const [olderMentions, setOlderMentions] = React.useState([]); + const [exhausted, setExhausted] = React.useState(false); + const [isFetchingOlder, setIsFetchingOlder] = React.useState(false); + const inFlightRef = React.useRef(false); + + const baseMentions = baseFeed?.feed.mentions; + + const feed = React.useMemo((): HomeFeedResponse | undefined => { + if (!baseFeed) return undefined; + if (olderMentions.length === 0) return baseFeed; + + const seen = new Set(baseFeed.feed.mentions.map((item) => item.id)); + const older = olderMentions.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + + return { + ...baseFeed, + feed: { + ...baseFeed.feed, + mentions: [...baseFeed.feed.mentions, ...older], + }, + }; + }, [baseFeed, olderMentions]); + + const fetchOlder = React.useCallback(() => { + if (inFlightRef.current || exhausted) return; + // Without channel types the cursor cannot mirror the relay's DM + // conversation key; paginating now could skip conversations. Wait. + if (!baseMentions || !channels) return; + + const cursor = getOldestConversationActivity( + [...baseMentions, ...olderMentions], + channels, + ); + if (cursor === null) return; + + inFlightRef.current = true; + setIsFetchingOlder(true); + void getHomeFeed({ + until: cursor, + limit: HOME_FEED_OLDER_PAGE_LIMIT, + types: "mentions", + }) + .then((response) => { + const known = new Set([ + ...baseMentions.map((item) => item.id), + ...olderMentions.map((item) => item.id), + ]); + const fresh = response.feed.mentions.filter( + (item) => !known.has(item.id), + ); + if (fresh.length === 0) { + setExhausted(true); + return; + } + setOlderMentions((previous) => [...previous, ...fresh]); + }) + .catch(() => { + // Leave state unchanged: the next scroll-end retries. + }) + .finally(() => { + inFlightRef.current = false; + setIsFetchingOlder(false); + }); + }, [baseMentions, channels, exhausted, olderMentions]); + + return { feed, fetchOlder, isFetchingOlder, hasOlder: !exhausted }; +} diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs index cd2f78c80c..6a0d3e1552 100644 --- a/desktop/src/features/home/lib/inbox.test.mjs +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -6,6 +6,7 @@ import { findInboxItemByEventId, getInboxConversationId, getInboxTypeLabel, + getOldestConversationActivity, } from "./inbox.ts"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -594,3 +595,65 @@ test("nested-anchor: old selected event stays resolvable by conversationId after // The new representative is the latest reply. assert.equal(inboxItem.id, LATEST_EVENT_ID); }); + +test("getOldestConversationActivity returns min of per-conversation latest, not oldest event", () => { + // Conversation A (thread root-a): replies at t=100 and t=500 → conv_latest 500. + // Conversation B (top-level event b-only): single event at t=300 → conv_latest 300. + // Oldest event overall is 100, but the cursor must be min(conv_latest) = 300. + const mentions = [ + item({ + id: "a-old", + createdAt: 100, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-a", "", "root"], + ["e", "root-a", "", "reply"], + ], + }), + item({ + id: "a-new", + createdAt: 500, + tags: [ + ["h", CHANNEL_ID], + ["e", "root-a", "", "root"], + ["e", "a-old", "", "reply"], + ], + }), + item({ + id: "b-only", + createdAt: 300, + tags: [["h", CHANNEL_ID]], + }), + ]; + + assert.equal(getOldestConversationActivity(mentions, channels), 300); +}); + +test("getOldestConversationActivity groups DM events by channel", () => { + // Two events in the same DM channel are one conversation (conv_latest 400), + // even though their thread tags differ. + const mentions = [ + item({ + id: "dm-old", + createdAt: 200, + channelId: DM_CHANNEL_ID, + tags: [["h", DM_CHANNEL_ID]], + }), + item({ + id: "dm-new", + createdAt: 400, + channelId: DM_CHANNEL_ID, + tags: [ + ["h", DM_CHANNEL_ID], + ["e", "dm-root-2", "", "root"], + ["e", "dm-root-2", "", "reply"], + ], + }), + ]; + + assert.equal(getOldestConversationActivity(mentions, channels), 400); +}); + +test("getOldestConversationActivity returns null for no mentions", () => { + assert.equal(getOldestConversationActivity([], channels), null); +}); diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..b4c090e568 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -430,6 +430,51 @@ export function getInboxItemConversationId(item: FeedItem) { ); } +/** + * Pagination cursor for the mentions feed: the oldest conversation's latest + * activity across the events in hand. + * + * The relay pages the mentions feed on `conv_latest` (the conversation's + * newest event time), so the next-page `until` must be the smallest + * `conv_latest` we have — NOT the oldest event time. An event-time cursor + * would be older than the boundary conversation's `conv_latest` whenever that + * conversation carries several events, and every conversation whose latest + * activity falls in that gap would be skipped, never to surface. + * + * Grouping must mirror the relay's key (`dm:` / thread root), which + * needs channel types to recognize DMs — callers must not paginate before the + * channel list has loaded. Returns `null` when no mention events are in hand. + */ +export function getOldestConversationActivity( + mentions: readonly FeedItem[], + channels?: InboxChannel[], +): number | null { + const channelById = new Map( + (channels ?? []).map((channel) => [channel.id, channel]), + ); + const latestByConversation = new Map(); + + for (const item of mentions) { + const channelType = resolveItemChannel(item, channelById).type; + const key = getInboxConversationId( + item.tags, + item.id, + item.channelId, + channelType, + item.kind, + ); + const latest = latestByConversation.get(key) ?? 0; + if (item.createdAt > latest) { + latestByConversation.set(key, item.createdAt); + } + } + + if (latestByConversation.size === 0) { + return null; + } + return Math.min(...latestByConversation.values()); +} + /** Finds the Inbox row containing an event, including grouped events. */ export function findInboxItemByEventId( items: readonly InboxItem[], diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index b6512816e3..b003e1a28a 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; -import { useHomeFeedQuery } from "@/features/home/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useHomeFeedPagination, useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import type { HomeFeedResponse } from "@/shared/api/types"; import { @@ -25,25 +26,32 @@ export function HomeScreen({ onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); + const channelsQuery = useChannelsQuery(); const { threadActivityFeedItems } = useAppShell(); + // Older inbox pages, cursor-fetched on scroll-end and merged under the + // live (30s-polled) first page. + const pagination = useHomeFeedPagination( + homeFeedQuery.data, + channelsQuery.data, + ); const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => { - if (!homeFeedQuery.data) return undefined; + if (!pagination.feed) return undefined; if (threadActivityFeedItems.length === 0) { - return homeFeedQuery.data; + return pagination.feed; } return { - ...homeFeedQuery.data, + ...pagination.feed, feed: { - ...homeFeedQuery.data.feed, + ...pagination.feed.feed, activity: [ - ...homeFeedQuery.data.feed.activity, + ...pagination.feed.feed.activity, ...threadActivityFeedItems, ], }, }; - }, [homeFeedQuery.data, threadActivityFeedItems]); + }, [pagination.feed, threadActivityFeedItems]); return (
@@ -61,6 +69,8 @@ export function HomeScreen({ } feed={augmentedFeed} isLoading={homeFeedQuery.isLoading} + isLoadingOlder={pagination.isFetchingOlder} + onLoadOlder={pagination.hasOlder ? pagination.fetchOlder : undefined} onOpenContext={onOpenContext} onRefresh={() => { void homeFeedQuery.refetch(); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c..e06789bda9 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -93,6 +93,9 @@ type HomeViewProps = { threadRootId?: string | null, ) => void; onRefresh: () => void; + /** Fetch the next page of older conversations (scroll-end pagination). */ + onLoadOlder?: () => void; + isLoadingOlder?: boolean; }; export function HomeView({ @@ -103,6 +106,8 @@ export function HomeView({ availableChannelIds, onOpenContext, onRefresh, + onLoadOlder, + isLoadingOlder = false, }: HomeViewProps) { const relaySelfPubkey = useRelaySelfQuery().data; const [homeInboxRef, homeInboxWidthPx] = useElementWidth(); @@ -111,10 +116,8 @@ export function HomeView({ homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; const [filter, setFilter] = React.useState("all"); const [unreadOnly, setUnreadOnly] = React.useState(false); - // Explicit selections are mirrored to the URL (`?item=`), so back/forward - // restores the detail pane each history entry was showing and reloads - // restore it from the URL. Default/automatic selection stays local-only — - // background data loads must never trigger navigations. + // Explicit selections mirror to `?item=` so back/forward/reload restore the + // detail pane; automatic selection stays local-only (no navigations). const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } = useHistorySearchState(INBOX_SEARCH_KEYS); const isReminders = filter === "reminders"; @@ -145,9 +148,9 @@ export function HomeView({ isReminders, viewportWidthPx: homeInboxWidthPx, }); - // `?item=` is Messages-mode-only machinery: a reminder never enters the - // FeedItem selection model, so reload while in Reminders mode keeps a stale - // `?item=` unconsumed and does not snap back to a feed-item detail view. + // `?item=` is Messages-mode-only: a reminder never enters the FeedItem + // selection model, so a Reminders-mode reload keeps a stale `?item=` + // unconsumed rather than snapping back to a feed-item detail view. const urlSelectedItemId = isMessagesMode ? inboxSearchValues.item : null; const profilePanelPubkey = inboxSearchValues.profile; const profilePanelTab = profilePanelTabFromSearch( @@ -265,11 +268,8 @@ export function HomeView({ }); const threadContextFeedItem = activeLatchedItem; - // Derive the default composer parent from the active anchor's own tags so - // that InboxDetailPane can recover the original reply target even when the - // anchor event has been displaced from the current groupItems. This is null - // until the active item is resolved (anchor not yet found in feedItems and - // no matching committed latch). + // Default composer parent from the anchor's own tags: InboxDetailPane can + // recover the reply target even after groupItems displaced the anchor. const latchedDefaultParentId = activeLatchedItem !== null ? (getThreadReference(activeLatchedItem.tags).parentId ?? @@ -417,11 +417,9 @@ export function HomeView({ : null, [inboxItems, selectedEventId], ); - // selectedConversationId: prefer the InboxItem-derived conversationId (stable - // group key). Fall back to deriving it from the latched FeedItem when the - // anchored event is no longer present in any group's items — this keeps the - // correct row selected (by conversationId) even after the anchor event has - // been displaced from groupItems by a newer representative. + // selectedConversationId: prefer the InboxItem-derived conversationId + // (stable group key); fall back to deriving it from the latched FeedItem + // when a newer representative displaced the anchored event from groupItems. const latchedConversationId = activeLatchedItem ? getInboxItemConversationId(activeLatchedItem) : null; @@ -444,9 +442,9 @@ export function HomeView({ selectedConversationId, unreadOnly, ]); - // A filter change may only retain detail for a conversation that remains - // visible. The filter handler selects the next valid row in the same update, - // so the detail pane never renders a stale conversation between states. + // A filter change may only retain detail for a still-visible conversation; + // the handler selects the next valid row in the same update, so the detail + // pane never renders a stale conversation between states. const selectedItem = React.useMemo(() => { if (!selectedEventId) return null; const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId); @@ -695,9 +693,11 @@ export function HomeView({ doneSet={effectiveDoneSet} dueReminderCount={dueReminderCount} filter={filter} + isLoadingOlder={isLoadingOlder} items={filteredItems} onDeleteDraft={handleDeleteDraft} onFilterChange={handleFilterChange} + onLoadOlder={onLoadOlder} onMarkRead={markItemRead} onMarkUnread={markItemUnread} onOpenDirect={(item) => { diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 17b06bf284..7916fc0365 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,11 @@ -import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; +import { + Bell, + Clock, + Ellipsis, + ExternalLink, + Loader2, + MailOpen, +} from "lucide-react"; import * as React from "react"; import { @@ -179,9 +186,16 @@ type InboxListPaneProps = { draftItems: DraftViewItem[]; doneSet: ReadonlySet; filter: InboxFilter; + /** True while an older page is in flight — renders the list-tail spinner. */ + isLoadingOlder?: boolean; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; onDeleteDraft: (draftKey: string) => void; + /** + * Fetch the next page of older conversations. Called when the list scrolls + * near its end; absent when pagination is unavailable or exhausted. + */ + onLoadOlder?: () => void; onMarkRead: (itemId: string) => void; onMarkUnread: (itemId: string) => void; onOpenDirect: (item: InboxItem) => void; @@ -207,9 +221,11 @@ export function InboxListPane({ draftItems, doneSet, filter, + isLoadingOlder = false, items, onFilterChange, onDeleteDraft, + onLoadOlder, onMarkRead, onMarkUnread, onOpenDirect, @@ -231,6 +247,33 @@ export function InboxListPane({ const isDrafts = filter === "drafts"; const isMixedInboxView = filter === "all"; const scrollRef = React.useRef(null); + + // Scroll-end pagination: when the list scrolls within a viewport of its + // bottom, ask for the next page of older conversations. Listener-based (not + // row-visibility) so it works with the virtualized rows, and re-checked on + // items growth so a short page that doesn't fill the viewport still chains + // to the next fetch. + const onLoadOlderRef = React.useRef(onLoadOlder); + onLoadOlderRef.current = onLoadOlder; + // biome-ignore lint/correctness/useExhaustiveDependencies: items.length + isLoadingOlder are intentional re-check triggers — a short page that doesn't fill the viewport must chain to the next fetch + React.useEffect(() => { + const scrollEl = scrollRef.current; + if (!scrollEl || !onLoadOlder) { + return; + } + const maybeLoadOlder = () => { + const { scrollTop, clientHeight, scrollHeight } = scrollEl; + if (scrollHeight - (scrollTop + clientHeight) < clientHeight) { + onLoadOlderRef.current?.(); + } + }; + scrollEl.addEventListener("scroll", maybeLoadOlder, { passive: true }); + maybeLoadOlder(); + return () => { + scrollEl.removeEventListener("scroll", maybeLoadOlder); + }; + }, [onLoadOlder, items.length, isLoadingOlder]); + const inboxRows = React.useMemo( () => buildInboxListRows({ @@ -610,44 +653,54 @@ export function InboxListPane({ ref={scrollRef} > {visibleInboxRows.length > 0 ? ( - row.key} - items={visibleInboxRows} - renderItem={(row) => { - if (row.kind === "inbox") { - return renderItem(row.item, row.dueReminder); - } + <> + row.key} + items={visibleInboxRows} + renderItem={(row) => { + if (row.kind === "inbox") { + return renderItem(row.item, row.dueReminder); + } - const source = reminderSources.get(row.reminder.id); - return ( - { - onSelectReminder(row.reminder.id); - }} - preview={ - row.reminder.content.target?.preview || - row.reminder.content.note || - "Reminder" - } - selected={selectedReminderId === row.reminder.id} - status={formatReminderStatus(row.reminder.notBefore)} - /> - ); - }} - scrollRef={scrollRef} - /> + const source = reminderSources.get(row.reminder.id); + return ( + { + onSelectReminder(row.reminder.id); + }} + preview={ + row.reminder.content.target?.preview || + row.reminder.content.note || + "Reminder" + } + selected={selectedReminderId === row.reminder.id} + status={formatReminderStatus(row.reminder.notBefore)} + /> + ); + }} + scrollRef={scrollRef} + /> + {isLoadingOlder ? ( +
+ +
+ ) : null} + ) : (
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef625783..fe0cc1ff4c 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -246,6 +246,8 @@ export type HomeFeedResponse = { export type GetHomeFeedInput = { since?: number; + /** Inclusive pagination cursor — see getOldestConversationActivity. */ + until?: number; limit?: number; types?: string; }; @@ -1014,11 +1016,7 @@ export type GlobalAgentConfig = { preferred_runtime: string | null; }; -/** - * Result returned by `set_global_agent_config`. - * - * Mirrors the Rust `GlobalAgentConfigSaveResult` struct. - */ +/** Result returned by `set_global_agent_config`. Mirrors the Rust `GlobalAgentConfigSaveResult` struct. */ export type GlobalAgentConfigSaveResult = { /** The persisted global config (after strip-on-write). */ config: GlobalAgentConfig; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 65decf04c5..ad9de58bfc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7096,6 +7096,7 @@ async function handleLeaveChannel( async function handleGetFeed( args: { since?: number; + until?: number; limit?: number; types?: string; }, @@ -7336,6 +7337,9 @@ async function handleGetFeed( ): RawFeedItem[] => includeType(category) ? [...mockFeedOverrides[category], ...defaultFeed[category]] + .filter( + (item) => args.until == null || item.created_at <= args.until, + ) .sort((left, right) => right.created_at - left.created_at) .slice(0, limit) : []; @@ -7373,27 +7377,32 @@ async function handleGetFeed( // faithful here is what lets the inbox windowing e2e exercise the same // relay path the desktop app uses. const limit = args.limit ?? 50; - const mentionEvents = await relayQuery(config, [ - { - feed_types: ["mentions"], - kinds: [ - 9, - 40002, - 1, - 45001, - 45003, - KIND_GIT_PULL_REQUEST, - KIND_GIT_PR_UPDATE, - KIND_GIT_ISSUE, - KIND_GIT_STATUS_OPEN, - KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, - ], - "#p": [identity.pubkey], - limit, - }, - ]); + const mentionFilter: Record = { + feed_types: ["mentions"], + kinds: [ + 9, + 40002, + 1, + 45001, + 45003, + KIND_GIT_PULL_REQUEST, + KIND_GIT_PR_UPDATE, + KIND_GIT_ISSUE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + ], + "#p": [identity.pubkey], + limit, + }; + // Mirror the native bridge: `until` rides the nostr filter into the feed + // path, where it bounds each conversation's latest activity (conv_latest) + // — the pagination cursor for older inbox pages. + if (args.until != null) { + mentionFilter.until = args.until; + } + const mentionEvents = await relayQuery(config, [mentionFilter]); // Look up channel names for feed items const channelIdsInFeed = [ diff --git a/desktop/tests/e2e/inbox-pagination-screenshots.spec.ts b/desktop/tests/e2e/inbox-pagination-screenshots.spec.ts new file mode 100644 index 0000000000..0293c4a412 --- /dev/null +++ b/desktop/tests/e2e/inbox-pagination-screenshots.spec.ts @@ -0,0 +1,133 @@ +/** + * Inbox cursor pagination — scroll-end loads older conversations. + * + * PR #5834 windowed the mentions feed per conversation, which fixed + * starvation but capped the inbox at the newest N conversations. This + * change adds an inclusive `until` cursor bounding each conversation's + * latest activity (conv_latest): scroll-end fetches the next page of whole + * conversations at-or-older than the oldest conversation in hand. + * + * The test seeds more standalone conversations than one page holds. The + * first page cannot contain the oldest markers; scrolling to the bottom of + * the inbox list must fetch older pages until they surface. + * + * Run (against an isolated relay): + * BUZZ_E2E_RELAY_URL=http://localhost:3030 pnpm build:e2e && \ + * pnpm exec playwright test --project=integration \ + * tests/e2e/inbox-pagination-screenshots.spec.ts + * Output: test-results/inbox-pagination/ + */ +import { expect, test } from "@playwright/test"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent, type VerifiedEvent } from "nostr-tools/pure"; + +import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { scrollInboxListToText } from "../helpers/inboxScroll"; +import { assertRelaySeeded } from "../helpers/seed"; + +const SHOTS = "test-results/inbox-pagination"; +const RELAY_HTTP = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; + +// uuid5("buzz.channel.general") — fixed id seeded by +// scripts/setup-desktop-test-data.sh. +const GENERAL_ID = "9f28288a-d724-587a-9709-92dc7f967110"; + +// One page is 50 events; every seeded mention is its own conversation, so +// 60 conversations guarantees the oldest ones start beyond the first page. +const CONVERSATION_COUNT = 60; + +// Unique per run so reruns against a shared relay stay deterministic: the +// assertions only track this run's markers, never leftovers. +const RUN_ID = Math.random().toString(36).slice(2, 8); + +async function publish( + sender: keyof typeof TEST_IDENTITIES, + channelId: string, + content: string, + createdAt: number, +): Promise { + const event = finalizeEvent( + { + kind: 9, + content, + created_at: createdAt, + tags: [ + ["h", channelId], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + }, + hexToBytes(TEST_IDENTITIES[sender].privateKey), + ); + const response = await fetch(`${RELAY_HTTP}/events`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Pubkey": event.pubkey }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error( + `POST /events failed (${response.status}): ${await response.text()}`, + ); + } + return event; +} + +function marker(index: number) { + return `paged mention ${index} [${RUN_ID}]`; +} + +/** + * Seed CONVERSATION_COUNT standalone mentions in #general — each one is its + * own conversation (no thread tags), index 0 oldest. + * + * The relay rejects events drifting more than ±15 min from server time + * (ingest.rs MAX_TIMESTAMP_DRIFT_SECS), so the timeline is compressed into + * the last ~13 minutes: 10s spacing from −780s. Ordering matters, not span. + */ +async function seedPagedConversations() { + const nowSecond = Math.floor(Date.now() / 1000); + for (let i = 0; i < CONVERSATION_COUNT; i++) { + await publish("bob", GENERAL_ID, marker(i), nowSecond - 780 + i * 10); + } +} + +test.describe("inbox cursor pagination", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeAll(async () => { + test.setTimeout(120_000); + await assertRelaySeeded(); + await seedPagedConversations(); + }); + + test("scroll-end loads older conversations beyond the first page", async ({ + page, + }) => { + test.setTimeout(120_000); + await installRelayBridge(page, "tyler"); + await page.goto("/"); + const list = page.getByTestId("home-inbox-list"); + await expect(list).toBeVisible(); + + // Page 1 (newest 50 conversations) holds the newest marker… + await expect(list).toContainText(marker(CONVERSATION_COUNT - 1), { + timeout: 15_000, + }); + // …and cannot hold the oldest: it is beyond the first page's window. + await expect(list).not.toContainText(marker(0)); + await page.screenshot({ + path: `${SHOTS}/01-before-first-page-only.png`, + fullPage: true, + }); + + // Scroll toward the bottom until the oldest conversation pages in. Each + // scroll-end triggers a cursor fetch; the step-scroll helper walks the + // virtualized list a viewport at a time so mid-list rows render too. + await scrollInboxListToText(page, list, marker(0)); + await expect(list).toContainText(marker(0)); + + await page.screenshot({ + path: `${SHOTS}/02-after-scroll-loaded-older.png`, + fullPage: true, + }); + }); +}); diff --git a/desktop/tests/e2e/inbox-windowing-screenshots.spec.ts b/desktop/tests/e2e/inbox-windowing-screenshots.spec.ts index 156ea3ba22..77e938cbf7 100644 --- a/desktop/tests/e2e/inbox-windowing-screenshots.spec.ts +++ b/desktop/tests/e2e/inbox-windowing-screenshots.spec.ts @@ -26,6 +26,7 @@ import { hexToBytes } from "@noble/hashes/utils.js"; import { finalizeEvent, type VerifiedEvent } from "nostr-tools/pure"; import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { scrollInboxListToText } from "../helpers/inboxScroll"; import { assertRelaySeeded } from "../helpers/seed"; const SHOTS = "test-results/inbox-windowing"; @@ -194,9 +195,12 @@ test.describe("inbox feed windowing", () => { await expect(list).toContainText(`chatty dm update ${CHATTY_COUNT - 1}`, { timeout: 15_000, }); - // …and every standalone mention conversation survives the window. + // …and every standalone mention conversation survives — on the first + // page when the relay holds few conversations, or reachable via + // scroll-end cursor pagination when other (newer) conversations from + // sibling specs share the relay. Either way, nothing is starved out. for (let i = 0; i < STANDALONE_COUNT; i++) { - await expect(list).toContainText(standaloneMarker(i)); + await scrollInboxListToText(page, list, standaloneMarker(i)); } await page.screenshot({ diff --git a/desktop/tests/helpers/inboxScroll.ts b/desktop/tests/helpers/inboxScroll.ts new file mode 100644 index 0000000000..01fc6fd161 --- /dev/null +++ b/desktop/tests/helpers/inboxScroll.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from "@playwright/test"; + +/** + * Step-scroll the (virtualized) inbox list until `text` renders. + * + * Rows outside the viewport are not in the DOM, so a single jump-to-bottom + * can skip past a mid-list row entirely. Scroll one viewport at a time; at + * the bottom, give cursor pagination a beat to extend the list, and wrap + * back to the top when it doesn't (live polling can prepend rows and shift + * earlier content out of the rendered window). + */ +export async function scrollInboxListToText( + page: Page, + list: Locator, + text: string, + timeoutMs = 90_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await list.getByText(text).count()) > 0) return; + const atBottom = await list.evaluate((el) => { + const bottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4; + if (!bottom) el.scrollTop += el.clientHeight * 0.8; + return bottom; + }); + if (atBottom) { + const heightBefore = await list.evaluate((el) => el.scrollHeight); + await page.waitForTimeout(1_000); + const heightAfter = await list.evaluate((el) => el.scrollHeight); + if (heightAfter <= heightBefore) { + await list.evaluate((el) => { + el.scrollTop = 0; + }); + } + } + await page.waitForTimeout(150); + } + throw new Error(`"${text}" never rendered in the inbox list`); +}