eleven / messenger Public

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>
Paul Campbell cfb56d72865a6a4169a7675c5361a61dfba838cb parent ed38c73
6 files changed, +203 −15
  • db.go +22 −0
  • db_test.go +27 −0
  • handlers_test.go +39 −0
  • main.go +18 −2
  • web/static/chat.js +78 −13
  • web/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;