feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user