paginated messages: load latest 100, scroll-up for older

Backend:
- Add before_seq + limit params to list_messages
- When limit set without after_seq, ORDER BY DESC then reverse (gets latest page)
- Reject after_seq + before_seq together (400)
- Cap limit at 1000, no default change (CLI compat)

Frontend:
- Initial load fetches ?limit=100, scrolls to bottom
- Scroll near top triggers ?before_seq=lowestSeq&limit=100
- useLayoutEffect maintains scroll position after prepend
- Gap repair loops after_seq fetches until caught up
- Auto-scroll only when near bottom (doesn't yank while reading)
- "loading older..." and "beginning of conversation" indicators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 08:47:03 +02:00
parent 73696bc58c
commit e0b93ab141
4 changed files with 232 additions and 88 deletions

View File

@@ -124,6 +124,8 @@ pub struct AckRequest {
#[derive(Debug, Deserialize)]
pub struct MessageQuery {
pub after_seq: Option<i64>,
pub before_seq: Option<i64>,
pub limit: Option<i64>,
pub r#type: Option<MessageType>,
pub user_id: Option<Uuid>,
}

View File

@@ -186,6 +186,11 @@ pub async fn list_messages(
Path(channel_id): Path<String>,
Query(query): Query<MessageQuery>,
) -> Result<Json<Vec<Message>>> {
// Reject conflicting cursors
if query.after_seq.is_some() && query.before_seq.is_some() {
return Err(AppError::BadRequest("Cannot use both after_seq and before_seq".into()));
}
let mut sql = String::from(
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
FROM messages m JOIN users u ON m.user_id = u.id \
@@ -193,10 +198,16 @@ pub async fn list_messages(
);
let mut binds: Vec<String> = vec![channel_id.clone()];
// Cursor filtering
if let Some(after_seq) = &query.after_seq {
sql.push_str(" AND m.seq > ?");
binds.push(after_seq.to_string());
}
if let Some(before_seq) = &query.before_seq {
sql.push_str(" AND m.seq < ?");
binds.push(before_seq.to_string());
}
if let Some(msg_type) = &query.r#type {
sql.push_str(" AND m.type = ?");
binds.push(match msg_type {
@@ -213,7 +224,21 @@ pub async fn list_messages(
binds.push(user_id.to_string());
}
sql.push_str(" ORDER BY m.seq ASC");
// When limit is set without after_seq, fetch the LATEST messages
// (ORDER BY DESC LIMIT N, then reverse to chronological order)
let use_desc = query.limit.is_some() && query.after_seq.is_none();
if use_desc {
sql.push_str(" ORDER BY m.seq DESC");
} else {
sql.push_str(" ORDER BY m.seq ASC");
}
// Apply limit (capped at 1000)
if let Some(limit) = &query.limit {
let capped = (*limit).min(1000).max(1);
sql.push_str(&format!(" LIMIT {}", capped));
}
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
for b in &binds {
@@ -221,7 +246,13 @@ pub async fn list_messages(
}
let rows = q.fetch_all(&state.db).await?;
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
let mut messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
// Reverse DESC results back to chronological order
if use_desc {
messages.reverse();
}
Ok(Json(messages))
}