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>
7 files changed,
+421
−7
changelog/2026-07-14-copy-message-link.md+6 −0links.go+186 −0links_test.go+109 −0main.go+5 −0web/static/apps/chat.js+41 −5web/static/icons.js+7 −0web/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], |