Pin chats to the top of the list (#66)
web: "Pin chat / Unpin chat" in the thread kebab menu; pinned chats float
to the top of the list (stable pinnedFirst helper in ui.js, unit-tested)
with a small tilted pushpin badge next to the timestamp.
server: per-user pinned_threads marker (same shape as hidden_threads),
POST /api/threads/{id}/pinned, and pinned-first ordering in threadsForUser
so every client syncs the same order across devices.
ios: decode the pinned flag and sort the native sidebar pinned-first
(toggle stays web/Mac-side for now — the embed hides the web header).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+206
−7
changelog/2026-07-02-pin-chats.md+1 −0db.go+28 −3handlers_test.go+73 −0ios/LChat/Models.swift+2 −0ios/LChat/ThreadStore.swift+3 −1main.go+34 −0test/ui.test.mjs+15 −0web/pages/index.html+1 −0web/static/chat.js+39 −3web/static/style.css+2 −0web/static/ui.js+8 −0
diff --git a/changelog/2026-07-02-pin-chats.md b/changelog/2026-07-02-pin-chats.md| new file mode 100644 |
| index 0000000..8afe6ae |
| --- /dev/null |
| +++ b/changelog/2026-07-02-pin-chats.md |
| @@ -0,0 +1 @@ |
| +You can now pin your favourite chats to the top of the list — open a chat, tap the ⋮ menu, and choose "Pin chat". Pins are yours alone and follow you across your devices. |
diff --git a/db.go b/db.go| index 6eea34b..9a21e7c 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -178,6 +178,13 @@ CREATE TABLE IF NOT EXISTS hidden_threads ( |
| thread_id TEXT NOT NULL, |
| PRIMARY KEY (user_id, thread_id) |
| ); |
| +-- per-user pinned chats: threads that user keeps at the top of their list |
| +-- (synced across their devices). Just a marker — no content. |
| +CREATE TABLE IF NOT EXISTS pinned_threads ( |
| + user_id TEXT NOT NULL, |
| + thread_id TEXT NOT NULL, |
| + PRIMARY KEY (user_id, thread_id) |
| +); |
| -- Console-managed config for an agent (bot), keyed by its user id: the model, |
| -- system prompt (personality), and reference docs the sidecar applies. This is |
| -- operator config, not message content; the sidecar pulls it with its own token. |
| @@ -1018,6 +1025,9 @@ type Thread struct { |
| Avatar string `json:"avatar,omitempty"` |
| // Hidden is true when the requesting user has closed (hidden) this chat. |
| Hidden bool `json:"hidden,omitempty"` |
| + // Pinned is true when the requesting user keeps this chat at the top of |
| + // their list. |
| + Pinned bool `json:"pinned,omitempty"` |
| } |
| // ThreadMemberKey pairs a member with the thread key wrapped to their pubkey. |
| @@ -1138,12 +1148,14 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) { |
| COALESCE((SELECT blob FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), ''), |
| COALESCE((SELECT created_at FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), 0), |
| COALESCE(t.avatar_blob, ''), |
| - EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id) |
| + EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id), |
| + EXISTS(SELECT 1 FROM pinned_threads p WHERE p.thread_id = t.id AND p.user_id = m.user_id) AS pinned |
| FROM threads t |
| JOIN thread_members m ON m.thread_id = t.id |
| LEFT JOIN thread_keys k ON k.thread_id = t.id AND k.user_id = m.user_id |
| WHERE m.user_id = ? |
| - ORDER BY COALESCE((SELECT MAX(created_at) FROM messages WHERE thread_id = t.id), t.created_at) DESC, |
| + ORDER BY pinned DESC, |
| + COALESCE((SELECT MAX(created_at) FROM messages WHERE thread_id = t.id), t.created_at) DESC, |
| t.created_at DESC`, userID) |
| if err != nil { |
| return nil, err |
| @@ -1152,7 +1164,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) { |
| out := []Thread{} |
| for rows.Next() { |
| var t Thread |
| - if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Hidden); err != nil { |
| + if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Hidden, &t.Pinned); err != nil { |
| return nil, err |
| } |
| out = append(out, t) |
| @@ -1199,6 +1211,19 @@ func (db *DB) setThreadHidden(userID, threadID string, hidden bool) error { |
| return err |
| } |
| +// setThreadPinned pins (pinned=true) or unpins (pinned=false) a chat for one |
| +// user — a per-user marker, synced across their devices via getThreads. |
| +func (db *DB) setThreadPinned(userID, threadID string, pinned bool) error { |
| + if pinned { |
| + _, err := db.sql.Exec( |
| + "INSERT OR IGNORE INTO pinned_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID, |
| + ) |
| + return err |
| + } |
| + _, err := db.sql.Exec("DELETE FROM pinned_threads WHERE user_id = ? AND thread_id = ?", userID, threadID) |
| + return err |
| +} |
| + |
| // clearThreadHidden reopens a thread for every user who had closed it — called on |
| // new activity so a closed chat returns to the list when it's messaged. |
| func (db *DB) clearThreadHidden(threadID string) error { |
diff --git a/handlers_test.go b/handlers_test.go| index bc8429f..ba5c5e3 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -662,6 +662,79 @@ func TestThreadHiddenAndRemove(t *testing.T) { |
| } |
| } |
| +// Pinning a chat floats it to the top of that user's list (synced via |
| +// getThreads) and survives new activity elsewhere; unpinning restores |
| +// recency order. Per-user: one member's pin never affects another's list. |
| +func TestThreadPinned(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + tokA, uidA, tid1 := joinedMember(t, srv, "Alice") |
| + // A second thread with newer activity, so tid1 only sorts first when pinned. |
| + tid2, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}}) |
| + if err != nil { |
| + t.Fatalf("createThread: %v", err) |
| + } |
| + if _, err := srv.db.insertMessage(tid2, "blob", "author"); err != nil { |
| + t.Fatalf("insertMessage: %v", err) |
| + } |
| + |
| + post := func(path, body string) int { |
| + resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post %s: %v", path, err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + threadsForAlice := func() []Thread { |
| + resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) |
| + if err != nil { |
| + t.Fatalf("getThreads: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct { |
| + Threads []Thread `json:"threads"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + return out.Threads |
| + } |
| + |
| + if got := threadsForAlice(); got[0].ID != tid2 { |
| + t.Fatalf("before pinning, newest-activity thread should sort first, got %q", got[0].ID) |
| + } |
| + |
| + if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":true}`); code != 200 { |
| + t.Fatalf("pin: got %d", code) |
| + } |
| + got := threadsForAlice() |
| + if got[0].ID != tid1 || !got[0].Pinned { |
| + t.Fatalf("pinned thread should sort first with pinned=true, got id=%q pinned=%v", got[0].ID, got[0].Pinned) |
| + } |
| + // New activity in the other thread must not displace the pin. |
| + if _, err := srv.db.insertMessage(tid2, "blob2", "author"); err != nil { |
| + t.Fatalf("insertMessage: %v", err) |
| + } |
| + if got := threadsForAlice(); got[0].ID != tid1 { |
| + t.Fatal("pinned thread should stay first over newer activity elsewhere") |
| + } |
| + |
| + if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":false}`); code != 200 { |
| + t.Fatalf("unpin: got %d", code) |
| + } |
| + got = threadsForAlice() |
| + if got[0].ID != tid2 || got[0].Pinned { |
| + t.Fatalf("after unpinning, recency order should return, got id=%q pinned=%v", got[0].ID, got[0].Pinned) |
| + } |
| + |
| + // Non-members can't pin. |
| + tokB, _, _ := joinedMember(t, srv, "Bob") |
| + if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokB+`","pinned":true}`); code != 403 { |
| + t.Fatalf("non-member pin should be 403, got %d", code) |
| + } |
| +} |
| + |
| // joinedMember sets up a real "joined" member — a user with a device token and a |
| // registered public key — plus a thread they belong to, for exercising the |
| // thread-scoped APIs. Returns the device token, the user id, and the thread id. |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 77d4724..df12498 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -38,6 +38,7 @@ struct ServerThread: Codable { |
| var last_blob: String? |
| var last_at: Int64? |
| var avatar: String? |
| + var pinned: Bool? |
| } |
| struct RosterEntry: Codable, Equatable { |
| @@ -88,6 +89,7 @@ struct Thread: Identifiable, Equatable { |
| var preview: String // last-message snippet |
| var lastAt: Int64 // seconds; drives ordering + the relative time |
| var unread: Bool |
| + var pinned: Bool // user pinned this chat to the top (set on the web/Mac client) |
| var members: [String] |
| var threadKeyB64: String // unwrapped — handed to the webview to render the feed |
| } |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index d8375e8..85e3b74 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -75,11 +75,13 @@ final class ThreadStore: ObservableObject { |
| preview: preview, |
| lastAt: lastAt, |
| unread: lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| + pinned: t.pinned ?? false, |
| members: t.members ?? [], |
| threadKeyB64: raw.base64urlEncodedString() |
| )) |
| } |
| - rows.sort { $0.lastAt > $1.lastAt } |
| + // Pinned chats first, then most-recent activity — mirrors the web list. |
| + rows.sort { ($0.pinned != $1.pinned) ? $0.pinned : $0.lastAt > $1.lastAt } |
| threads = rows |
| } |
diff --git a/main.go b/main.go| index 1818fe7..1c6d13f 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -145,6 +145,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/threads/{id}/cancel-invite", s.cancelInvite) |
| mux.HandleFunc("POST /api/threads/{id}/remove", s.postThreadRemove) |
| mux.HandleFunc("POST /api/threads/{id}/hidden", s.postThreadHidden) |
| + mux.HandleFunc("POST /api/threads/{id}/pinned", s.postThreadPinned) |
| // Encrypted attachments: bytes are E2E-encrypted in the browser; the server |
| // stores/serves opaque blobs only. Both ends are token-gated. |
| @@ -912,6 +913,39 @@ func (s *Server) postThreadHidden(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// postThreadPinned pins (pinned=true) or unpins (pinned=false) a chat for the |
| +// caller — a per-user marker that syncs across their devices and floats the |
| +// thread to the top of their list. It never affects anyone else. |
| +func (s *Server) postThreadPinned(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Pinned bool `json:"pinned"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok { |
| + httpError(w, http.StatusForbidden, "no user for token") |
| + return |
| + } |
| + threadID := r.PathValue("id") |
| + if !s.db.threadMember(threadID, uid) { |
| + httpError(w, http.StatusForbidden, "not a thread member") |
| + return |
| + } |
| + if err := s.db.setThreadPinned(uid, threadID, body.Pinned); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // postThreadRemove removes another member from a group. Any member may remove |
| // any other (matching how adding/renaming work today — the group is flat). Like |
| // leaving, the key isn't rotated: the server just stops delivering to them. |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 78db267..4cb9637 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -10,6 +10,7 @@ import { |
| normalizeAppearance, |
| colorScheme, |
| composerToken, |
| + pinnedFirst, |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| } from "../web/static/ui.js"; |
| @@ -131,5 +132,19 @@ await test("composerToken: only the token before the caret counts", () => { |
| assert.equal(t.query, "par"); |
| }); |
| +await test("pinnedFirst: floats pinned chats, keeping order within each band", () => { |
| + const list = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; |
| + const pins = { b: true, d: true }; |
| + assert.deepEqual( |
| + pinnedFirst(list, (t) => pins[t.id]).map((t) => t.id), |
| + ["b", "d", "a", "c"], |
| + ); |
| +}); |
| + |
| +await test("pinnedFirst: no pins leaves the list unchanged", () => { |
| + const list = [{ id: "a" }, { id: "b" }]; |
| + assert.deepEqual(pinnedFirst(list, () => false), list); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/pages/index.html b/web/pages/index.html| index d5f6b6a..d4c989e 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -107,6 +107,7 @@ |
| <button class="menu-item" id="thread-add" role="menuitem">Add people</button> |
| <button class="menu-item" id="thread-edit" role="menuitem">Edit group</button> |
| <button class="menu-item" id="thread-members" role="menuitem">View members</button> |
| + <button class="menu-item" id="thread-pin" role="menuitem">Pin chat</button> |
| <button class="menu-item" id="thread-close" role="menuitem">Close chat</button> |
| <button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button> |
| </div> |
diff --git a/web/static/chat.js b/web/static/chat.js| index fa221d8..a18aa95 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -28,6 +28,7 @@ import { |
| normalizeAppearance, |
| colorScheme, |
| composerToken, |
| + pinnedFirst, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -231,6 +232,12 @@ const ICON_TRASH = |
| const ICON_PLAY = |
| '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| '<path d="M8 5.5v13a1 1 0 0 0 1.53.85l10.2-6.5a1 1 0 0 0 0-1.7L9.53 4.65A1 1 0 0 0 8 5.5z"></path></svg>'; |
| +// Filled pushpin marking pinned chats in the thread list — solid so it stays |
| +// crisp at ~12px, tilted via CSS for the classic "tacked on" look. |
| +const ICON_PIN = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 ' + |
| + '3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z"></path></svg>'; |
| // Overlapping pages, for the "Copy" action in the mobile long-press menu. |
| const ICON_COPY = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
| @@ -299,6 +306,7 @@ const threadPreview = {}; // thread id -> last-message snippet |
| const inviteLinks = {}; |
| const unread = {}; // thread id -> bool |
| const hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server |
| +const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server |
| let chatFilter = ""; // lowercased substring filtering the thread list (search box) |
| let activeId = null; |
| let ws = null; |
| @@ -652,6 +660,7 @@ async function loadThreads() { |
| } |
| threadName[t.id] = await displayName(t); |
| hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list |
| + pinned[t.id] = !!t.pinned; // pinned chats (server-synced) float to the top |
| if (t.last_blob && threadKeys[t.id]) { |
| try { |
| threadPreview[t.id] = previewText(t, await decrypt(threadKeys[t.id], t.last_blob)); |
| @@ -845,6 +854,10 @@ function renderApp() { |
| closeTMenu(); |
| showMembers(); |
| }; |
| + document.getElementById("thread-pin").onclick = () => { |
| + closeTMenu(); |
| + togglePin(); |
| + }; |
| document.getElementById("thread-close").onclick = () => { |
| closeTMenu(); |
| closeChat(); |
| @@ -1022,10 +1035,11 @@ function renderThreadList() { |
| return; |
| } |
| // A search reveals everything that matches (so a closed chat can be found and |
| - // reopened); the default list hides closed chats. |
| + // reopened); the default list hides closed chats and floats pinned ones to the |
| + // top (bumpThread keeps recency order within each of the two bands). |
| const shown = chatFilter |
| ? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter)) |
| - : threads.filter((t) => !hidden[t.id]); |
| + : pinnedFirst(threads.filter((t) => !hidden[t.id]), (t) => pinned[t.id]); |
| if (!shown.length) { |
| const empty = document.createElement("p"); |
| empty.className = "thread-empty-note"; |
| @@ -1067,9 +1081,15 @@ function renderThreadList() { |
| const time = document.createElement("span"); |
| time.className = "thread-row-time"; |
| time.textContent = relTime(t.last_at); |
| + meta.appendChild(time); |
| + if (pinned[t.id]) { |
| + const pin = document.createElement("span"); |
| + pin.className = "thread-row-pin"; |
| + pin.innerHTML = ICON_PIN; // static in-code SVG (safe) |
| + meta.appendChild(pin); |
| + } |
| const dot = document.createElement("span"); |
| dot.className = "unread-dot" + (unread[t.id] ? " on" : ""); |
| - meta.appendChild(time); |
| meta.appendChild(dot); |
| row.appendChild(meta); |
| @@ -1303,6 +1323,7 @@ function paintThreadHeader(id) { |
| const el = document.getElementById(gid); |
| if (el) el.style.display = isGroup ? "" : "none"; |
| } |
| + document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat"; |
| paintHeaderAvatar(id); |
| } |
| @@ -3324,6 +3345,21 @@ async function closeChat() { |
| showList(); |
| } |
| +// Pin/unpin the open chat: pinned chats float to the top of the list. Per-user, |
| +// synced across your devices. |
| +function togglePin() { |
| + const id = activeId; |
| + if (!id) return; |
| + pinned[id] = !pinned[id]; |
| + fetch(`/api/threads/${encodeURIComponent(id)}/pinned`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, pinned: pinned[id] }), |
| + }).catch(() => {}); |
| + document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat"; |
| + renderThreadList(); |
| +} |
| + |
| // addPeople adds joined instance members (not already in the group) by wrapping |
| // the group key to each of their roster public keys. |
| function addPeople() { |
diff --git a/web/static/style.css b/web/static/style.css| index 8c31c95..272005c 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1222,6 +1222,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* Time sits top-right, aligned with the name, even when the preview wraps to two lines. */ |
| .thread-row-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none; align-self: flex-start; padding-top: 3px; } |
| .thread-row-time { font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; } |
| +.thread-row-pin { color: var(--muted); flex: none; } |
| +.thread-row-pin svg { width: 15px; height: 15px; display: block; transform: rotate(45deg); } |
| .unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; } |
| .unread-dot.on { background: var(--accent); } |
| /* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active |
diff --git a/web/static/ui.js b/web/static/ui.js| index 0ec4209..e690581 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -63,6 +63,14 @@ export function composerToken(text, caret) { |
| return { kind: "mention", query, start: i - 1 }; |
| } |
| +// ---- chat list ordering -------------------------------------------------- |
| +// Stable pinned-first partition of the thread list: pinned chats float to the |
| +// top keeping their relative (recency) order, everything else follows in its |
| +// existing order. Pure, so the ordering rule is unit-tested in Node. |
| +export function pinnedFirst(list, isPinned) { |
| + return [...list.filter((t) => isPinned(t)), ...list.filter((t) => !isPinned(t))]; |
| +} |
| + |
| // ---- attachment policy -------------------------------------------------- |
| // Which non-image file types may be shared, keyed by extension. It's extension- |
| // driven (not MIME) because browsers report inconsistent — often empty — types |