eleven / ocho Public

web+server: message permalinks β€” "Copy link" mints a server-sealed, member-gated link (#893) (#907)

Copy a link to any message (click the timestamp, or "Copy link" in the
message menu); opening it jumps to and highlights the message, but only
resolves for a caller who is currently a member of that thread. The
thread/message reference is sealed under a dedicated per-instance server key
(never a thread/instance key a device could hold offline), so a since-removed
member's old links stop working without needing key rotation.

Ships as https://<instance-host>/#l=<token> for now β€” the shared 11ch.at
universal-link redirector isn't deployed yet, so emitting it early would
produce dead links (tracked separately in #905, a one-line URL-template
change once it exists; the mint/resolve/fragment-jump plumbing here doesn't
change).
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 0582b217386341adafe8ab5fd9ed0de7e4db356c parent 04929aa
7 files changed, +421 −7
  • changelog/2026-07-14-copy-message-link.md +6 −0
  • links.go +186 −0
  • links_test.go +109 −0
  • main.go +5 −0
  • web/static/apps/chat.js +41 −5
  • web/static/icons.js +7 −0
  • web/static/shell.js +67 −2
diff --git a/changelog/2026-07-14-copy-message-link.md b/changelog/2026-07-14-copy-message-link.md
new file mode 100644
index 0000000..3025bb1
--- /dev/null
+++ b/changelog/2026-07-14-copy-message-link.md
@@ -0,0 +1,6 @@
+πŸ€– You can now copy a link to any message β€” click its timestamp, or use "Copy
+link" in the reply/react menu. Opening the link jumps straight to that
+message and highlights it, but only works for other members of that chat: the
+link carries no readable trace of the message or its contents, even if it
+ends up posted somewhere else, and stops working for anyone who later leaves
+the chat.
diff --git a/links.go b/links.go
new file mode 100644
index 0000000..f63b553
--- /dev/null
+++ b/links.go
@@ -0,0 +1,186 @@
+// Message permalinks (issue #893): "Copy link" mints an opaque token for a
+// {thread_id, message_id} pair, sealed under a per-instance key that only this
+// server holds β€” never a thread or instance key, and never sent to any client.
+// A permalink exists to be pasted somewhere (another chat, an email, a synced
+// clipboard), so the reference has to be opaque to whoever sees the raw link
+// text, not just kept out of request logs. Sealing it under a key every thread
+// member holds would do that too, but as a static, offline capability with no
+// way to revoke it short of rotating the thread key. Sealing it under a key
+// only the server holds means opening a link is always a fresh, authenticated
+// round-trip that checks the caller's CURRENT thread membership β€” so leaving a
+// thread (or the space) actually revokes the ability to jump via an old link.
+//
+// The key only ever protects identifiers this server already stores in
+// plaintext (thread_id, message id) β€” never message content β€” so it's
+// addressing/auth material in the same category as the admin token, not a
+// decryption key for anything the E2EE rule protects. See the design
+// discussion on #893 for the full comparison against thread-key/instance-key
+// alternatives.
+package main
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+// ensureLinkKey returns this instance's message-link sealing key, generating
+// and persisting a fresh 256-bit one (config key "link_key") on first use.
+// Dedicated to this one purpose β€” not derived from or shared with any other
+// secret β€” so rotating it (or losing it, e.g. a fresh DB) only ever invalidates
+// outstanding permalinks, nothing else.
+func (db *DB) ensureLinkKey() ([]byte, error) {
+ v, ok, err := db.getConfig("link_key")
+ if err != nil {
+ return nil, err
+ }
+ if ok {
+ return base64.StdEncoding.DecodeString(v)
+ }
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ if err := db.setConfig("link_key", base64.StdEncoding.EncodeToString(key)); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+// sealLinkToken encrypts "threadID:messageID" under key with a fresh random
+// nonce (AES-256-GCM), same base64url(nonce||ciphertext) envelope used
+// elsewhere in this codebase for server-sealed tokens (home/stateless.go).
+func sealLinkToken(key []byte, threadID string, messageID int64) (string, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return "", err
+ }
+ pt := []byte(threadID + ":" + strconv.FormatInt(messageID, 10))
+ return base64.RawURLEncoding.EncodeToString(gcm.Seal(nonce, nonce, pt, nil)), nil
+}
+
+// openLinkToken reverses sealLinkToken. A tampered, expired-format, or
+// wrong-key token fails closed (GCM's tag, not the caller, decides "invalid").
+func openLinkToken(key []byte, token string) (threadID string, messageID int64, ok bool) {
+ raw, err := base64.RawURLEncoding.DecodeString(token)
+ if err != nil {
+ return "", 0, false
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", 0, false
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", 0, false
+ }
+ if len(raw) < gcm.NonceSize() {
+ return "", 0, false
+ }
+ pt, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
+ if err != nil {
+ return "", 0, false
+ }
+ threadID, idStr, found := strings.Cut(string(pt), ":")
+ if !found {
+ return "", 0, false
+ }
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ return "", 0, false
+ }
+ return threadID, id, true
+}
+
+// mintMessageLink mints the opaque token for a "Copy link" action. The caller
+// must already be a member of the thread the message lives in β€” same gate as
+// reading or reacting to it.
+func (s *Server) mintMessageLink(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ messageID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || messageID <= 0 {
+ httpError(w, http.StatusBadRequest, "bad message id")
+ return
+ }
+ var body struct {
+ ThreadID string `json:"thread_id"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(token)
+ if body.ThreadID == "" || !s.db.threadMember(body.ThreadID, userID) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ if !s.db.messageInThread(messageID, body.ThreadID) {
+ httpError(w, http.StatusNotFound, "message not found")
+ return
+ }
+ key, err := s.db.ensureLinkKey()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ tok, err := sealLinkToken(key, body.ThreadID, messageID)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "seal error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"token": tok})
+}
+
+// resolveMessageLink turns an opened "Copy link" token back into
+// {thread_id, message_id} β€” but only for a caller who is a member of that
+// thread RIGHT NOW. Every failure (tampered token, unknown thread, not a
+// member, message gone) returns the exact same generic response, so the
+// endpoint can never be used to probe thread membership or existence.
+func (s *Server) resolveMessageLink(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ var body struct {
+ Token string `json:"token"`
+ }
+ notFound := func() { httpError(w, http.StatusNotFound, "link not found") }
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Token == "" {
+ notFound()
+ return
+ }
+ key, err := s.db.ensureLinkKey()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ threadID, messageID, ok := openLinkToken(key, body.Token)
+ if !ok {
+ notFound()
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(token)
+ if !s.db.threadMember(threadID, userID) || !s.db.messageInThread(messageID, threadID) {
+ notFound()
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"thread_id": threadID, "message_id": messageID})
+}
diff --git a/links_test.go b/links_test.go
new file mode 100644
index 0000000..9566444
--- /dev/null
+++ b/links_test.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// sealLinkToken/openLinkToken round-trip, and a wrong key fails closed
+// (GCM authentication, not string comparison, decides "invalid").
+func TestLinkTokenRoundTrip(t *testing.T) {
+ key := make([]byte, 32)
+ tok, err := sealLinkToken(key, "thread-abc", 42)
+ if err != nil {
+ t.Fatalf("seal: %v", err)
+ }
+ tid, mid, ok := openLinkToken(key, tok)
+ if !ok || tid != "thread-abc" || mid != 42 {
+ t.Fatalf("open: got tid=%q mid=%d ok=%v", tid, mid, ok)
+ }
+ otherKey := make([]byte, 32)
+ otherKey[0] = 1
+ if _, _, ok := openLinkToken(otherKey, tok); ok {
+ t.Fatal("token opened under the wrong key")
+ }
+ if _, _, ok := openLinkToken(key, "not-a-real-token"); ok {
+ t.Fatal("garbage token should not open")
+ }
+}
+
+// Two seals of the same pair produce different ciphertext (random nonce per
+// seal) β€” links to the same message aren't correlatable by their bytes alone.
+func TestLinkTokenNotCorrelatable(t *testing.T) {
+ key := make([]byte, 32)
+ a, _ := sealLinkToken(key, "thread-abc", 42)
+ b, _ := sealLinkToken(key, "thread-abc", 42)
+ if a == b {
+ t.Fatal("two seals of the same pair should not be identical")
+ }
+}
+
+// End-to-end: mint a link as a thread member, resolve it back to the same
+// ids. A non-member of the thread gets a generic not-found, same as an
+// invalid token β€” the endpoint must not be a membership oracle.
+func TestMintAndResolveMessageLink(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tokA, uidA, tidA := joinedMember(t, srv, "Alice")
+ tokB, _, _ := joinedMember(t, srv, "Bob") // a member of a different thread
+
+ m, err := srv.db.insertMessage(tidA, "blob", uidA)
+ if err != nil {
+ t.Fatalf("insertMessage: %v", err)
+ }
+
+ mint := func(tok, threadID string, msgID int64) (string, int) {
+ resp, err := http.Post(
+ ts.URL+"/api/messages/"+strconv.FormatInt(msgID, 10)+"/link?token="+tok,
+ "application/json",
+ strings.NewReader(`{"thread_id":"`+threadID+`"}`),
+ )
+ if err != nil {
+ t.Fatalf("mint post: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ return out.Token, resp.StatusCode
+ }
+ resolve := func(tok, linkToken string) (string, int64, int) {
+ resp, err := http.Post(ts.URL+"/api/resolve-link?token="+tok, "application/json",
+ strings.NewReader(`{"token":"`+linkToken+`"}`))
+ if err != nil {
+ t.Fatalf("resolve post: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ ThreadID string `json:"thread_id"`
+ MessageID int64 `json:"message_id"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ return out.ThreadID, out.MessageID, resp.StatusCode
+ }
+
+ linkTok, code := mint(tokA, tidA, m.ID)
+ if code != http.StatusOK || linkTok == "" {
+ t.Fatalf("mint: status=%d token=%q", code, linkTok)
+ }
+
+ if tid, mid, code := resolve(tokA, linkTok); code != http.StatusOK || tid != tidA || mid != m.ID {
+ t.Fatalf("resolve as member: status=%d tid=%q mid=%d", code, tid, mid)
+ }
+
+ if _, _, code := resolve(tokB, linkTok); code != http.StatusNotFound {
+ t.Fatalf("resolve as non-member: want 404, got %d", code)
+ }
+ if _, _, code := resolve(tokA, "garbage"); code != http.StatusNotFound {
+ t.Fatalf("resolve garbage token: want 404, got %d", code)
+ }
+
+ // Bob can't mint a link for a message in a thread he's not in.
+ if _, code := mint(tokB, tidA, m.ID); code != http.StatusForbidden {
+ t.Fatalf("mint as non-member: want 403, got %d", code)
+ }
+}
diff --git a/main.go b/main.go
index d95e425..da6c2af 100644
--- a/main.go
+++ b/main.go
@@ -445,6 +445,11 @@ func (s *Server) handler() http.Handler {
// (checkpoint.go; docs/tamper-evidence.md client sampling). Member-gated.
mux.HandleFunc("GET /api/checkpoint-proof", s.getCheckpointProof)
mux.HandleFunc("POST /api/messages", s.postMessage)
+ // Message permalinks ("Copy link", issue #893): mint an opaque, server-
+ // sealed token for a message; resolve turns it back into ids only for a
+ // caller who is currently a thread member (links.go).
+ mux.HandleFunc("POST /api/messages/{id}/link", s.mintMessageLink)
+ mux.HandleFunc("POST /api/resolve-link", s.resolveMessageLink)
mux.HandleFunc("GET /ws", s.handleWS)
// Threads & roster (see docs/threads-and-dms.md). The roster is the open
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 6a2940a..e83fce9 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -1103,10 +1103,21 @@ function renderItem(m, ctx) {
actions.appendChild(editBtn);
setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
}
- const timeEl = document.createElement("span");
- timeEl.className = "msg-time";
- timeEl.textContent = formatTime(m.ts);
- actions.appendChild(timeEl);
+ // The exact-time hover affordance doubles as a "Copy link" trigger (issue
+ // #893) β€” the Slack/Discord convention. A <button> (not a bare span) so it's
+ // keyboard-reachable and gets a real accessible name, not just visible text
+ // that reads as a plain timestamp.
+ const timeBtn = document.createElement("button");
+ timeBtn.type = "button";
+ timeBtn.className = "msg-time";
+ timeBtn.textContent = formatTime(m.ts);
+ timeBtn.title = "Copy link to this message";
+ timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`);
+ timeBtn.onclick = (e) => {
+ e.stopPropagation();
+ copyMessageLink(ctx, m);
+ };
+ actions.appendChild(timeBtn);
content.appendChild(actions);
}
@@ -1264,6 +1275,26 @@ export function scrollToMessage(near, sid) {
target.classList.add("flash");
}
+// "Copy link" (issue #893): mint the permalink through the host (it holds
+// session.token and the fetch to /api/messages/{id}/link β€” see shell.js
+// mintMessageLink) and write it to the clipboard. Same await-and-toast
+// pattern as the existing plain-text Copy action, just via the host for the
+// network round-trip a link mint needs that a local clipboard copy doesn't.
+async function copyMessageLink(ctx, m) {
+ if (!host || !host.mintMessageLink || !host.toast) return;
+ const url = await host.mintMessageLink(ctx.threadId, m.sid);
+ if (!url) {
+ host.toast("Couldn't copy link");
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(url);
+ host.toast("Link copied");
+ } catch {
+ host.toast("Couldn't copy link");
+ }
+}
+
function toggleReaction(m, emoji, ctx) {
if (!host) return;
const sid = m.sid;
@@ -1331,6 +1362,9 @@ function openMessagePicker(m, ctx, anchor) {
}
});
}
+ // Copy link (issue #893) β€” a shareable permalink that jumps a member
+ // straight to this message; lives right after Copy.
+ action(icon("link"), "Copy link", () => copyMessageLink(ctx, m));
// Same uid-or-name attribution as the feed, so the Edit/Delete actions show up
// on your own legacy (uid-less) messages too.
const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
@@ -1404,7 +1438,7 @@ function openMessagePicker(m, ctx, anchor) {
}
// The actions in the touch long-press sheet, in the original feed's order: Reply,
-// Copy, then app-contributed actions (Save link…), then your-own Edit (in-window)
+// Copy, Copy link, then app-contributed actions (Save link…), then your-own Edit (in-window)
// and Delete. Each is { label, icon | glyph, danger?, run } β€” mirrors messageActions
// in the original chat.js.
function sheetActions(m, ctx) {
@@ -1435,6 +1469,8 @@ function sheetActions(m, ctx) {
},
});
}
+ // Copy link (issue #893) β€” same permalink as the desktop picker's action.
+ acts.push({ label: "Copy link", icon: icon("link"), run: () => copyMessageLink(ctx, m) });
// Actions other mini-apps contribute (e.g. Bookmarks' "Save link") β€” each an
// emoji glyph rendered as textContent, never innerHTML.
for (const act of messageActionsFor({ text: m.text, from: m.name })) {
diff --git a/web/static/icons.js b/web/static/icons.js
index ff3f913..38376d9 100644
--- a/web/static/icons.js
+++ b/web/static/icons.js
@@ -72,6 +72,13 @@ export const ICON_COPY =
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="9" y="9" width="12" height="12" rx="2"></rect>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
+// Chain link, for the "Copy link" message-permalink action (issue #893).
+export const ICON_LINK =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M9 17H7A5 5 0 0 1 7 7h2"></path>' +
+ '<path d="M15 7h2a5 5 0 1 1 0 10h-2"></path>' +
+ '<line x1="8" y1="12" x2="16" y2="12"></line></svg>';
// Dropdown caret (the Views button). (Lucide: chevron-down)
export const ICON_CHEVRON_DOWN =
diff --git a/web/static/shell.js b/web/static/shell.js
index 5d34201..4878901 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -797,7 +797,7 @@ const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server en
const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000;
// The UI icon set (web/static/icons.js β€” Lucide idiom, docs/icons.md).
-import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js";
+import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js";
const app = document.getElementById("app");
@@ -1158,6 +1158,9 @@ async function boot() {
// A freshly-joined member gets Eleven's welcome instead (mutually exclusive
// with admin setup β€” a setup claim never sets the welcome marker).
await maybeStartWelcome();
+ // A tapped "Copy link" permalink (#l=, issue #893): resolve + jump last, so
+ // it always wins over whatever thread the normal boot flow landed in.
+ await openInitialMessageLink();
} catch (e) {
console.error(e);
// A failed network boot over a successful cached paint is the OFFLINE case
@@ -2830,6 +2833,41 @@ function initialThreadId() {
return m ? decodeURIComponent(m[1]) : null;
}
+// The opaque message-link token from a "Copy link" permalink fragment
+// (#l=<token>) β€” issue #893. Whether we arrived directly or via an
+// https://11ch.at/l/<host> redirect, the fragment survives (redirects with no
+// fragment of their own reattach the original), so this is the one landing
+// spot for both. Kept in the fragment for the same reason as #thread=: never
+// sent to this server, let alone 11ch.at's.
+function initialMessageLinkToken() {
+ const m = /(?:^|[#&])l=([^&]+)/.exec(location.hash || "");
+ return m ? decodeURIComponent(m[1]) : null;
+}
+
+// Consumes a pending #l= permalink fragment, if any: resolves the token via
+// this instance's own server (the only place that holds the key to open it β€”
+// links.go) and jumps to the message. The resolve call itself enforces CURRENT
+// thread membership, so a stale link a since-removed member held onto simply
+// stops working β€” nothing here trusts the fragment's contents on faith.
+async function openInitialMessageLink() {
+ const tok = initialMessageLinkToken();
+ if (!tok) return;
+ history.replaceState(null, "", location.pathname + location.search); // consume it, like #thread=
+ try {
+ const r = await fetch(`/api/resolve-link?token=${encodeURIComponent(session.token)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: tok }),
+ });
+ if (!r.ok) return;
+ const { thread_id, message_id } = await r.json();
+ if (thread_id && message_id) await jumpToMessage(thread_id, message_id);
+ } catch {
+ // Offline / server unreachable: the app still opens normally, just without
+ // the jump β€” same graceful degradation as a broken #thread= deep link.
+ }
+}
+
// Open a thread by id, tolerating one we haven't loaded yet (e.g. a notification
// for a thread created while this tab was asleep). Used by the SW deep-link.
async function openThreadById(id) {
@@ -4547,7 +4585,7 @@ setChatAppHost({
}
return null;
},
- icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, mute: ICON_MUTE, report: ICON_REPORT },
+ icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT },
// Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted,
// labelled, and run by the host β€” the app just mounts the returned row.
actionChips: (actions) => {
@@ -4576,6 +4614,33 @@ setChatAppHost({
// Report a message (issue #578): the sheet lives in the shell β€” it holds
// the roster and the keys that seal the report to each admin.
reportMessage: (threadId, m) => openReportSheet({ uid: m.uid, threadId, message: m }),
+ // Message permalinks ("Copy link", issue #893): mints an opaque, server-
+ // sealed token (links.go β€” never a thread/instance key, so decoding a link
+ // always re-checks CURRENT thread membership) and returns a shareable URL,
+ // or null on failure. The app writes it to the clipboard and toasts, same
+ // as the plain-text Copy action.
+ //
+ // This instance's own origin for now (openInitialMessageLink reads #l= from
+ // the fragment regardless of how it got there). The design target is a
+ // shared https://11ch.at/l/<host>#l=<token> redirector so the link opens
+ // natively on every device via one Associated Domain instead of one per
+ // instance host (see #893) β€” but that redirector isn't deployed yet, so
+ // emitting it today would produce dead links. Swap the template below for
+ // it once the service exists; nothing else here changes.
+ mintMessageLink: async (threadId, messageId) => {
+ try {
+ const r = await fetch(`/api/messages/${encodeURIComponent(messageId)}/link?token=${encodeURIComponent(session.token)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ thread_id: threadId }),
+ });
+ if (!r.ok) return null;
+ const { token } = await r.json();
+ return token ? `${location.origin}/#l=${encodeURIComponent(token)}` : null;
+ } catch {
+ return null;
+ }
+ },
// Closed DMs (issue #578): the app swaps its composer for the closed banner
// and offers Reopen; the server owns the state, the shell mirrors it.
dmClosed: (threadId) => !!dmClosed[threadId],