eleven / ocho Public

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>
Paul Campbell 0a22fbb780b1ceb6ae0c4762dde8783506fed69d parent 3424c50
11 files changed, +206 −7
  • changelog/2026-07-02-pin-chats.md +1 −0
  • db.go +28 −3
  • handlers_test.go +73 −0
  • ios/LChat/Models.swift +2 −0
  • ios/LChat/ThreadStore.swift +3 −1
  • main.go +34 −0
  • test/ui.test.mjs +15 −0
  • web/pages/index.html +1 −0
  • web/static/chat.js +39 −3
  • web/static/style.css +2 −0
  • web/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