chat: paginate history (last 100 + scroll-up), loading spinner, instant initial scroll
Initial load rendered the whole window and scrolled to the bottom after every message as each decrypted — a long animated scroll. Now: - load only the last 100; older pages load on scroll-up via a 'before' cursor (new messagesBefore + before/limit on /api/messages, carrying edited_at like messagesAfter), preserving scroll position. - render the initial batch without per-message scrolling, then jump to the bottom once (instant). Gap-fill on reconnect does the same and loops for large gaps. - spinner only if the initial load/decrypt exceeds 500ms. Re-applied on top of the message-editing PR (#2) after a concurrent merge; both features coexist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 files changed,
+203
−15
db.go+22 −0db_test.go+27 −0handlers_test.go+39 −0main.go+18 −2web/static/chat.js+78 −13web/static/style.css+19 −0
diff --git a/db.go b/db.go| index 9d931ff..2a83438 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -366,6 +366,28 @@ func (db *DB) editMessage(id int64, blob, authorHash string, window time.Duratio |
| return Message{ID: id, Blob: blob, EditedAt: now.Unix()}, true, nil |
| } |
| +// messagesBefore returns the `limit` messages immediately older than `before` |
| +// (id < before), ascending — used to paginate history upward. |
| +func (db *DB) messagesBefore(before int64, limit int) ([]Message, error) { |
| + rows, err := db.sql.Query( |
| + "SELECT id, created_at, blob, COALESCE(edited_at, 0) FROM (SELECT id, created_at, blob, edited_at FROM messages WHERE id < ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC", |
| + before, limit, |
| + ) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []Message{} |
| + for rows.Next() { |
| + var m Message |
| + if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob, &m.EditedAt); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, m) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // messagesAfter returns messages with id > after (ascending). When after is 0, |
| // it returns the most recent `limit` messages instead of the entire history. |
| func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) { |
diff --git a/db_test.go b/db_test.go| index a16b754..f574585 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -78,6 +78,33 @@ func TestMessages(t *testing.T) { |
| } |
| } |
| +func TestMessagesPagination(t *testing.T) { |
| + db := newTestDB(t) |
| + ids := make([]int64, 0, 5) |
| + for i := 0; i < 5; i++ { |
| + m, _ := db.insertMessage("b", "author-a") |
| + ids = append(ids, m.ID) |
| + } |
| + |
| + // after==0 returns the most recent `limit`, ascending. |
| + last2, _ := db.messagesAfter(0, 2) |
| + if len(last2) != 2 || last2[0].ID != ids[3] || last2[1].ID != ids[4] { |
| + t.Fatalf("messagesAfter(0,2): %+v", last2) |
| + } |
| + |
| + // before returns the `limit` messages just older than the cursor, ascending. |
| + before, _ := db.messagesBefore(ids[3], 2) |
| + if len(before) != 2 || before[0].ID != ids[1] || before[1].ID != ids[2] { |
| + t.Fatalf("messagesBefore(ids[3],2): %+v", before) |
| + } |
| + |
| + // Fewer than `limit` near the start signals no more older messages. |
| + head, _ := db.messagesBefore(ids[1], 5) |
| + if len(head) != 1 || head[0].ID != ids[0] { |
| + t.Fatalf("messagesBefore(ids[1],5): %+v", head) |
| + } |
| +} |
| + |
| func TestEditMessage(t *testing.T) { |
| db := newTestDB(t) |
| m, err := db.insertMessage("orig", "author-a") |
diff --git a/handlers_test.go b/handlers_test.go| index 4fff0dd..c6ae1bf 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "strconv" |
| "strings" |
| "testing" |
| ) |
| @@ -141,6 +142,44 @@ func TestMessagesIncludeReactions(t *testing.T) { |
| } |
| } |
| +func TestMessagesPaginationAPI(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| + var inv struct { |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&inv) |
| + |
| + ids := []int64{} |
| + for i := 0; i < 5; i++ { |
| + m, _ := srv.db.insertMessage("b", "author-a") |
| + ids = append(ids, m.ID) |
| + } |
| + get := func(q string) []int64 { |
| + r, _ := http.Get(ts.URL + "/api/messages?token=" + inv.Token + q) |
| + var out struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + } `json:"messages"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&out) |
| + got := []int64{} |
| + for _, m := range out.Messages { |
| + got = append(got, m.ID) |
| + } |
| + return got |
| + } |
| + |
| + // limit returns the most recent N, ascending. |
| + if g := get("&limit=2"); len(g) != 2 || g[0] != ids[3] || g[1] != ids[4] { |
| + t.Fatalf("limit=2: %v", g) |
| + } |
| + // before paginates older, ascending. |
| + if g := get("&before=" + strconv.FormatInt(ids[3], 10) + "&limit=2"); len(g) != 2 || g[0] != ids[1] || g[1] != ids[2] { |
| + t.Fatalf("before: %v", g) |
| + } |
| +} |
| + |
| func TestBlobUploadDownload(t *testing.T) { |
| ts, _ := newTestServer(t) |
| resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
diff --git a/main.go b/main.go| index a562995..922f122 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -173,8 +173,24 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "invalid invite") |
| return |
| } |
| - after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) |
| - msgs, err := s.db.messagesAfter(after, 200) |
| + q := r.URL.Query() |
| + limit := 100 |
| + if n, err := strconv.Atoi(q.Get("limit")); err == nil && n > 0 { |
| + limit = n |
| + } |
| + if limit > 200 { |
| + limit = 200 |
| + } |
| + // `before` paginates older history (id < before); otherwise `after` returns |
| + // the page after a cursor, or the most recent `limit` when after is 0. |
| + var msgs []Message |
| + if b := q.Get("before"); b != "" { |
| + before, _ := strconv.ParseInt(b, 10, 64) |
| + msgs, err = s.db.messagesBefore(before, limit) |
| + } else { |
| + after, _ := strconv.ParseInt(q.Get("after"), 10, 64) |
| + msgs, err = s.db.messagesAfter(after, limit) |
| + } |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
diff --git a/web/static/chat.js b/web/static/chat.js| index c71c5af..aa66e19 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -4,6 +4,7 @@ import { tokenizeText, formatBytes, replySnippet } from "/static/format.js"; |
| const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go) |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| +const PAGE_SIZE = 100; // messages per history page (initial load + scroll-up paging) |
| import { |
| needsHomeScreen, |
| pushSupported, |
| @@ -422,6 +423,22 @@ async function start(session) { |
| const replyCounts = new Map(); |
| const replyCountEls = new Map(); |
| + // Loading spinner, shown only if the initial load/decrypt is slow (>500ms). |
| + let chatLoadingEl = null; |
| + function showChatLoading() { |
| + if (chatLoadingEl) return; |
| + const wrap = app.querySelector(".feed-wrap"); |
| + if (!wrap) return; |
| + chatLoadingEl = document.createElement("div"); |
| + chatLoadingEl.className = "chat-loading"; |
| + chatLoadingEl.innerHTML = '<div class="spinner"></div>'; |
| + wrap.appendChild(chatLoadingEl); |
| + } |
| + function hideChatLoading() { |
| + if (chatLoadingEl) chatLoadingEl.remove(); |
| + chatLoadingEl = null; |
| + } |
| + |
| function bumpReplyCount(parentId) { |
| const n = (replyCounts.get(parentId) || 0) + 1; |
| replyCounts.set(parentId, n); |
| @@ -523,7 +540,9 @@ async function start(session) { |
| return b; |
| } |
| - async function render(msg) { |
| + // opts.before: insert before this node (for prepending older history). |
| + // opts.noScroll: don't auto-scroll (batch loads handle scrolling themselves). |
| + async function render(msg, opts = {}) { |
| if (seen.has(msg.id)) return; |
| seen.add(msg.id); |
| if (msg.id > lastId) lastId = msg.id; |
| @@ -607,10 +626,12 @@ async function start(session) { |
| stampTime(el, payload, msg.created_at, msg.edited_at); |
| - const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| - feed.appendChild(el); |
| + const atBottom = |
| + !opts.before && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| + if (opts.before) feed.insertBefore(el, opts.before); |
| + else feed.appendChild(el); |
| renderReactionsFor(msg.id); |
| - if (atBottom || mine) feed.scrollTop = feed.scrollHeight; |
| + if (!opts.noScroll && (atBottom || mine)) feed.scrollTop = feed.scrollHeight; |
| } |
| // Apply an authoritative edit broadcast: re-decrypt and rebuild that message's |
| @@ -971,10 +992,43 @@ async function start(session) { |
| setTimeout(() => URL.revokeObjectURL(url), 10000); |
| } |
| - // 1) Load recent history. |
| - const history = await fetchMessages(session.token, 0); |
| - for (const m of history) await render(m); |
| - feed.scrollTop = feed.scrollHeight; |
| + // 1) Load recent history — just the last PAGE_SIZE; older pages load on |
| + // scroll-up. Render without per-message scrolling, then jump to the bottom |
| + // once (no animation). Show a spinner only if the load/decrypt is slow. |
| + let oldestId = 0; |
| + let hasMoreOlder = false; |
| + let loadingOlder = false; |
| + |
| + const loadTimer = setTimeout(showChatLoading, 500); |
| + const history = await fetchMessages(session.token, { limit: PAGE_SIZE }); |
| + for (const m of history) await render(m, { noScroll: true }); |
| + clearTimeout(loadTimer); |
| + hideChatLoading(); |
| + if (history.length) { |
| + oldestId = history[0].id; |
| + hasMoreOlder = history.length >= PAGE_SIZE; |
| + } |
| + feed.scrollTop = feed.scrollHeight; // instant jump to the bottom |
| + |
| + // Cursor-paginate older history when scrolled near the top, preserving position. |
| + async function loadOlder() { |
| + if (loadingOlder || !hasMoreOlder || !oldestId) return; |
| + loadingOlder = true; |
| + const page = await fetchMessages(session.token, { before: oldestId, limit: PAGE_SIZE }); |
| + if (page.length) { |
| + const prevHeight = feed.scrollHeight; |
| + const prevTop = feed.scrollTop; |
| + const anchor = feed.firstChild; // older messages go in order above this |
| + for (const m of page) await render(m, { before: anchor, noScroll: true }); |
| + feed.scrollTop = prevTop + (feed.scrollHeight - prevHeight); // hold position |
| + oldestId = page[0].id; |
| + } |
| + if (page.length < PAGE_SIZE) hasMoreOlder = false; |
| + loadingOlder = false; |
| + } |
| + feed.addEventListener("scroll", () => { |
| + if (feed.scrollTop < 200) loadOlder(); |
| + }); |
| // 2) Live updates over WebSocket, with reconnect + gap fill. |
| let ws; |
| @@ -1006,9 +1060,15 @@ async function start(session) { |
| clearReconnectBanner(); |
| showBanner("", false); |
| startPing(); |
| - // Fill anything missed while disconnected. |
| - const missed = await fetchMessages(session.token, lastId); |
| - for (const m of missed) await render(m); |
| + // Fill anything missed while disconnected (loop in case the gap > a page). |
| + const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| + let got = PAGE_SIZE; |
| + while (got >= PAGE_SIZE) { |
| + const missed = await fetchMessages(session.token, { after: lastId, limit: PAGE_SIZE }); |
| + for (const m of missed) await render(m, { noScroll: true }); |
| + got = missed.length; |
| + } |
| + if (wasAtBottom) feed.scrollTop = feed.scrollHeight; |
| }; |
| ws.onmessage = (ev) => { |
| try { |
| @@ -1395,8 +1455,13 @@ async function start(session) { |
| input.focus(); |
| } |
| -async function fetchMessages(token, after) { |
| - const res = await fetch(`/api/messages?token=${encodeURIComponent(token)}&after=${after}`); |
| +// params: { after, before, limit } — see the /api/messages handler. |
| +async function fetchMessages(token, params = {}) { |
| + const qs = new URLSearchParams({ token }); |
| + for (const [k, v] of Object.entries(params)) { |
| + if (v !== undefined && v !== null) qs.set(k, v); |
| + } |
| + const res = await fetch(`/api/messages?${qs.toString()}`); |
| if (!res.ok) return []; |
| const data = await res.json(); |
| return data.messages || []; |
diff --git a/web/static/style.css b/web/static/style.css| index 74a45c1..c6d7887 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -151,6 +151,25 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| flex-direction: column; |
| min-height: 0; |
| } |
| +/* shown only if the initial history load/decrypt is slow (>500ms) */ |
| +.chat-loading { |
| + position: absolute; |
| + inset: 0; |
| + z-index: 10; |
| + display: flex; |
| + align-items: center; |
| + justify-content: center; |
| + background: var(--panel); |
| +} |
| +.spinner { |
| + width: 28px; |
| + height: 28px; |
| + border-radius: 50%; |
| + border: 3px solid var(--border); |
| + border-top-color: var(--accent); |
| + animation: spin 0.8s linear infinite; |
| +} |
| +@keyframes spin { to { transform: rotate(360deg); } } |
| #feed { |
| flex: 1; |
| min-height: 0; |