Skip to content

Infinite Scroll Pagination

All major list views — calls, plans, tasks, notifications, and messaging — use server-side offset pagination with client-side infinite scroll. This replaces the previous .limit(500) pattern, reducing initial load times and memory usage.

Every paginated API endpoint accepts two optional query parameters:

ParameterTypeDefaultRange
pageu6400-based
page_sizeu64501–100

Each endpoint returns a response struct with the data plus pagination metadata:

// Example: CallListResponse (same shape for all modules)
pub struct CallListResponse {
pub calls: Vec<Call>,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_count: u64,
}
EndpointResponse typeData field
GET /api/callsCallListResponsecalls
GET /api/plansPlanListResponseplans
GET /api/tasksTaskListResponsetasks
GET /api/notificationsNotificationListResponsenotifications
GET /api/messaging/contacts/:id/messagesContactMessagesResponsemessages
GET /api/messaging/unanswered-contactsUnansweredContactsResponsecontacts

Each API handler runs two queries against the database:

  1. Count query — lightweight SELECT COUNT(*) with the same filters (no joins or ordering).
  2. Data query — applies .offset(page * page_size).limit(page_size) with ordering and joins.
// Count
let total_count: i64 = count_query
.select_only()
.column_as(Column::Id.count(), "count")
.into_tuple()
.one(&db)
.await?
.unwrap_or(0);
let total_pages = (total_count as u64 + page_size - 1) / page_size;
// Data
let items = query
.order_by(Column::CreatedAt, Order::Desc)
.offset(page * page_size)
.limit(page_size)
.all(&db)
.await?;

Every list view follows the same four-signal pattern:

let mut accumulated_items = use_signal(Vec::<Item>::new);
let mut current_page = use_signal(|| 0u64);
let mut total_pages = use_signal(|| 1u64);
let mut is_loading_more = use_signal(|| false);
  1. Seed — A use_resource fetches page 0. A use_effect seeds accumulated_items from the response.
  2. Reset — When filters change (time range, tab, status), a reactive use_effect clears accumulated items and resets current_page to 0.
  3. Load more — A use_future attaches a JS scroll listener to the scroll container. When the user scrolls within 100px of the bottom, it sends a message via dioxus.send().
  4. Append — The Rust side receives the message, fetches the next page, and extends accumulated_items.
use_future(move || async move {
let mut handle = document::eval(r#"
(() => {
const el = document.getElementById('my-scroll-container');
if (!el) return;
el._paginationHandler = () => {
const nearBottom = el.scrollTop + el.clientHeight
>= el.scrollHeight - 100;
if (nearBottom && !el._paginationCooldown) {
el._paginationCooldown = true;
dioxus.send('load-more');
setTimeout(() => { el._paginationCooldown = false; }, 500);
}
};
el.addEventListener('scroll', el._paginationHandler);
})();
"#);
while let Ok(_) = handle.recv::<String>().await {
// fetch next page, append to accumulated_items
}
});

The 500ms cooldown prevents duplicate fetches during fast scrolling.

The communication feed loads older messages when you scroll up. The use_paginated_messages hook in src/mods/messaging/hooks/use_paginated_messages.rs handles this with scroll position preservation:

  1. Capture scrollHeight and scrollTop before prepending.
  2. Prepend older messages to the front of the accumulated list.
  3. Restore scroll position: scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight).

This prevents the viewport from jumping when older messages are inserted above.

  1. Create a response type with items, page, page_size, total_pages, total_count fields.
  2. Add page and page_size query params to the API endpoint.
  3. Run a count query before the data query with matching filters.
  4. In the view, add the four signals, seed/reset effects, and scroll listener.
  5. Show a WaveIndicator when is_loading_more is true (replaces the old Spinner).