feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+137
View File
@@ -155,6 +155,28 @@
* the error banner. Phase 14's local persistence is untouched: saving is
* an additional, explicit action.
*
* Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md
* L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN,
* revealed at boot in the SAME admin-reveal block as Save) turns the
* CURRENT conversation into a public read-only link (/shared/<token>,
* a 128-bit uuid4 on the saved_chats row). The save-then-share
* contract: the SAME empty-conversation no-op guard as Save (live
* region, no request); linked (currentChatId set) → POST
* /api/chats/<id>/share (idempotent — the existing token comes back
* unchanged); unlinked → POST /api/chats with { messages: conversation,
* share: true } and link currentChatId to the created id — one action
* saves AND shares (owner-locked). On success the ABSOLUTE share URL
* (share_url resolved against the page origin) is copied:
* navigator.clipboard.writeText in a try — a non-secure (http) homelab
* origin rejects the clipboard, so the failure path renders the inline
* fallback: a transient link field near the status line (an <a> styled
* input-like that selects its full URL on focus, .share-link-fallback)
* and the live region reads "Share link ready — copy it from the
* field." (the owner-locked fallback). Success (clipboard) reads
* "Share link copied." 403/5xx → the actionable error banner (signed-
* in hint, like Save); a network failure → the "is the app reachable?"
* banner.
*
* All DOM ids match frontend/index.html.
*/
@@ -178,6 +200,7 @@ const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
* (the classic assets/brand.js sets it at parse time; its /api/config
@@ -1135,6 +1158,116 @@ async function saveCurrentChat() {
}
}
/* ---------- share the conversation (phase 51, owner-locked 2026-08-29) ---------- */
/* The share link's ABSOLUTE URL: the API reports the PATH
* (/shared/<token>); the owner's own origin supplies the scheme/host —
* a homelab http origin stays http (never assume https). */
function absoluteShareUrl(shareUrl) {
return new URL(shareUrl, window.location.origin).toString();
}
/* Select every text node in an element — the link field's
* select-on-focus (an <a> has no .select(); a range does the job).
* Best-effort: selection failure only means the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
}
}
/* Clipboard copy with the owner-locked inline-link fallback: a
* non-secure (http) homelab origin rejects navigator.clipboard, so the
* failure path renders a TRANSIENT <a> link field near the status line
* (appended to the composer, beside the send button that carries
* #send-status) — input-like, it selects its full URL on focus (click
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
* the old). Returns true when the clipboard took it. */
async function copyShareLinkWithFallback(absoluteUrl) {
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
composer.appendChild(field); // near the status line (inside the send button)
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
}
}
/* Share the current conversation — the #share-chat-btn handler
* (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live-
* region line when there is nothing to share (the same guard as
* Save). The save-then-share branch: linked → POST
* /api/chats/<id>/share (idempotent token); unlinked → POST /api/chats
* with { messages, share: true } and link to the created id — one
* action saves AND shares (owner-locked). Success copies the absolute
* URL (clipboard → inline-field fallback); the live region reads
* "Share link copied." or "Share link ready — copy it from the
* field." 403/5xx → the actionable banner (signed-out hint); a network
* failure → the reachable? banner. The double-click guard releases in
* the finally — never stale (PLAN §7.4). */
async function shareCurrentChat() {
if (!conversation.length) {
sendStatus.textContent = "Nothing to share yet.";
return;
}
if (shareBtn.disabled) return; // one share at a time (double-click guard)
shareBtn.disabled = true;
try {
let shareUrl;
if (currentChatId) {
// Linked (already saved): the idempotent share — an existing
// token comes back unchanged, a new one is minted.
const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
if (!res.ok) {
showErrorBanner(
"Couldn't share the conversation — check you're still signed in and try again."
);
return;
}
shareUrl = (await res.json()).share_url;
} else {
// Unsaved: save AND share in ONE action (owner-locked) — the
// server sets the 128-bit uuid4 token in the same commit.
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: conversation, share: true }),
});
if (!res.ok) {
showErrorBanner(
"Couldn't share the conversation — check you're still signed in and try again."
);
return;
}
const created = await res.json();
currentChatId = String(created.id); // one action saved AND shared: link
shareUrl = created.share_url;
}
const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl));
sendStatus.textContent = copied
? "Share link copied."
: "Share link ready — copy it from the field.";
} catch {
showErrorBanner("Couldn't share the conversation — is the app reachable?");
} finally {
shareBtn.disabled = false; // released on EVERY outcome — never stale
}
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
@@ -1550,6 +1683,9 @@ composer.addEventListener("submit", handleSend);
* IIFE below reveals it for admin (absent-not-hidden for anonymous,
* phase 16). Status-only feedback — the live region, never stale. */
saveBtn?.addEventListener("click", saveCurrentChat);
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
shareBtn?.addEventListener("click", shareCurrentChat);
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
@@ -1585,6 +1721,7 @@ window.addEventListener("pagehide", () => {
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore.
const openedSaved = await restoreSavedChatFromUrl();