diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index ab9f272e8..0d2e99390 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -38,7 +38,9 @@ export const chatInstances = { name: "chat-sotce", allowedHost: "chat.sotce.net", userInfoEndpoint: "https://sotce.us.auth0.com/userinfo", - topic: "mood", + // Dedicated topic — sotce chat pings must never fan out to the shared + // "mood" topic that aesthetic.computer devices subscribe to. + topic: "chat-sotce", }, "chat-clock.aesthetic.computer": { name: "chat-clock", @@ -618,9 +620,9 @@ export class ChatManager { }); } - // Push notification (production only, non-muted, chat-system and chat-clock only) - // Note: chat-sotce is intentionally excluded from push notifications - if (!this.dev && !userIsMuted && instance.config.name !== "chat-sotce") { + // Push notification (production only, non-muted). chat-sotce rides its + // own "chat-sotce" topic, which only sotce.net readers opt into. + if (!this.dev && !userIsMuted) { this.notify(instance, handle, filteredText, when); } } catch (err) { diff --git a/system/netlify/functions/register-push-token.mjs b/system/netlify/functions/register-push-token.mjs index e159256b3..1c836c0a2 100644 --- a/system/netlify/functions/register-push-token.mjs +++ b/system/netlify/functions/register-push-token.mjs @@ -28,6 +28,7 @@ const KNOWN_TOPICS = [ "chat-system", "chat-sotce", "chat-clock", + "sotce-pages", ]; const MAX_LABEL = 64; @@ -81,11 +82,28 @@ export async function handler(event) { } } - const topics = Array.isArray(body.topics) + let topics = Array.isArray(body.topics) ? body.topics.filter((t) => KNOWN_TOPICS.includes(t)) : ["scream", "mood"]; - const user = await authorize(event.headers); // null is fine (anonymous) + // sotce.net registrations authorize against the sotce tenant and store the + // user under a "sotce-"-prefixed sub (the repo-wide cross-tenant convention), + // so sends can target either tenant's users without sub collisions. + const tenant = body.tenant === "sotce" ? "sotce" : "aesthetic"; + const user = await authorize(event.headers, tenant); // null is fine (anonymous) + const userKey = user?.sub + ? (tenant === "sotce" ? "sotce-" : "") + user.sub + : null; + + // Sotce topics carry reader-side content (chat text), so they require a + // signed-in sotce account — never an anonymous registration. + const SOTCE_TOPICS = ["chat-sotce", "sotce-pages"]; + if ( + topics.some((t) => SOTCE_TOPICS.includes(t)) && + !(tenant === "sotce" && userKey) + ) { + topics = topics.filter((t) => !SOTCE_TOPICS.includes(t)); + } const database = await connect(); try { @@ -111,13 +129,13 @@ export async function handler(event) { updatedAt: new Date(), }; - if (user?.sub) { + if (userKey) { // Bind to user; claim the token from any previous account on this // device, and drop stale rows for this device (rotated endpoints). - doc.user = user.sub; - await collection.deleteMany({ token, user: { $ne: user.sub } }); + doc.user = userKey; + await collection.deleteMany({ token, user: { $ne: userKey } }); await collection.deleteMany({ - user: user.sub, + user: userKey, deviceId, token: { $ne: token }, }); diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index d3a8048ea..b4dadb3b9 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -93,6 +93,7 @@ import { userIDFromEmail, } from "../../backend/authorization.mjs"; import * as KeyValue from "../../backend/kv.mjs"; +import { broadcastToTopic, sendToUser } from "../../../shared/push.mjs"; import Stripe from "stripe"; import crypto from "node:crypto"; @@ -111,6 +112,49 @@ const SHELL_CACHE_MAX = 40; const SHELL_MODIFIED = new Date(Math.floor(Date.now() / 1000) * 1000); const SHELL_MODIFIED_HTTP = SHELL_MODIFIED.toUTCString(); +// 🔔 Service worker for web push — no fetch handler, so it never touches +// caching or page loads. Payloads come encrypted from shared/push.mjs as +// { title, body, icon, image, data: { piece } }. +const SW_SOURCE = `// sotce.net service worker — web push delivery only. +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener("push", (event) => { + let note = {}; + try { + note = event.data?.json() || {}; + } catch { + note = { body: event.data?.text() }; + } + event.waitUntil( + self.registration.showNotification(note.title || "Sotce Net", { + body: note.body || "", + icon: note.icon || "https://assets.aesthetic.computer/sotce-net/cookie.png", + image: note.image, + data: note.data || {}, + }) + ); +}); + +// Tapping a notification opens its page ("" = the diary, "chat" = chat), +// reusing an open tab when there is one. +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const piece = event.notification.data?.piece || ""; + const url = self.location.origin + "/" + piece; + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((tabs) => { + const tab = tabs.find((t) => t.url.startsWith(self.location.origin)); + if (tab) { + tab.focus(); + return tab.navigate ? tab.navigate(url) : undefined; + } + return clients.openWindow(url); + }) + ); +}); +`; + const dateOptions = { weekday: "long", year: "numeric", @@ -4772,6 +4816,44 @@ export const handler = async (event, context) => { buttons.push(genSubscribeButton("resubscribe")); } + // 🔔 Notifications toggle — new pages, answered questions, + // and chat messages arrive as web notifications. + if (pushSupported()) { + const nb = cel("button"); + nb.id = "notifications-toggle"; + nb.innerText = "notifications"; + notificationsOn().then(function (on) { + nb.innerText = on + ? "notifications: on" + : "notifications: off"; + }); + nb.onclick = async function () { + if (nb.disabled) return; + nb.disabled = true; + try { + if (await notificationsOn()) { + await disableNotifications(); + nb.innerText = "notifications: off"; + } else { + const ok = await enableNotifications(); + nb.innerText = ok + ? "notifications: on" + : "notifications: off"; + if (!ok && Notification.permission === "denied") { + alert( + "🔕 Notifications are blocked for this site in your browser settings.", + ); + } + } + } catch (err) { + console.error("🔔 Notification toggle error:", err); + nb.innerText = "notifications: off"; + } + nb.disabled = false; + }; + buttons.push(nb); + } + curtain.classList.add("hidden"); // if (GATE_WAS_UP) cookieWrapper.classList.add("interactive"); } @@ -9851,6 +9933,128 @@ export const handler = async (event, context) => { } } + // 🔔 Web push — pages, answered questions, and chat arrive as + // notifications once a reader opts in. Server side: shared/push.mjs. + const VAPID_PUBLIC_KEY = + "BIgGeN262eCK5bDaTdifFEsyvgcd6wwRztK_H7m6uhM49egJZsUKz2tiTVgjlD-JypqyVnvTqL3iZK3L4tAeFKk"; + + function pushSupported() { + return ( + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); + } + + function pushDeviceId() { + let id; + try { + id = localStorage.getItem("sotce-push-device-id"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("sotce-push-device-id", id); + } + } catch (err) { + id = crypto.randomUUID(); + } + return id; + } + + function pushDeviceLabel() { + const ua = navigator.userAgent; + let browser = "Browser"; + if (ua.includes("Edg/")) browser = "Edge"; + else if (ua.includes("Chrome/")) browser = "Chrome"; + else if (ua.includes("Firefox/")) browser = "Firefox"; + else if (ua.includes("Safari/")) browser = "Safari"; + let os = ""; + if (ua.includes("iPhone") || ua.includes("iPad")) os = "iOS"; + else if (ua.includes("Android")) os = "Android"; + else if (ua.includes("Mac")) os = "macOS"; + else if (ua.includes("Windows")) os = "Windows"; + else if (ua.includes("Linux")) os = "Linux"; + return browser + (os ? " on " + os : ""); + } + + async function pushSubscriptionNow() { + if (!pushSupported()) return null; + const reg = await navigator.serviceWorker.getRegistration("/sw.js"); + if (!reg) return null; + return await reg.pushManager.getSubscription(); + } + + async function notificationsOn() { + try { + if (!pushSupported()) return false; + if (Notification.permission !== "granted") return false; + return !!(await pushSubscriptionNow()); + } catch (err) { + return false; + } + } + + async function enableNotifications() { + if (!pushSupported()) { + alert("This browser does not support notifications."); + return false; + } + const permission = await Notification.requestPermission(); + if (permission !== "granted") return false; + const reg = await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + const keyBytes = Uint8Array.from( + atob( + VAPID_PUBLIC_KEY.replace(/-/g, "+").replace(/_/g, "/"), + ), + function (c) { + return c.charCodeAt(0); + }, + ); + let sub = await reg.pushManager.getSubscription(); + sub = + sub || + (await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: keyBytes, + })); + const token = + window.sotceTOKEN || (await auth0Client.getTokenSilently()); + const res = await fetch("/api/register-push-token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify({ + kind: "webpush", + subscription: sub.toJSON(), + deviceId: pushDeviceId(), + label: pushDeviceLabel(), + platform: "web", + topics: ["sotce-pages", "chat-sotce"], + tenant: "sotce", + }), + }); + if (res.ok) console.log("🔔 Notifications enabled."); + return res.ok; + } + + async function disableNotifications() { + const sub = await pushSubscriptionNow(); + if (!sub) return; + fetch("/api/register-push-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "webpush", + token: sub.endpoint, + remove: true, + }), + }).catch(function () {}); + await sub.unsubscribe(); + console.log("🔕 Notifications disabled."); + } + // Stripe.js loads on demand — only the checkout flow needs it. function loadStripeJs() { if (window.Stripe) return Promise.resolve(); @@ -10289,6 +10493,12 @@ export const handler = async (event, context) => { } return respond(200, body, shellHeaders); + } else if (path === "/sw.js" && method === "get") { + // 🔔 Web push service worker. (Caddy's @serviceworker rule adds the + // no-cache header, so none is set here.) + return respond(200, SW_SOURCE, { + "Content-Type": "application/javascript; charset=utf-8", + }); } else if (path === "/subscribers" && method === "get") { // Counting means paginating every Stripe subscription ever (seconds of // API calls), and the logged-out gate blocks on this response — so cache @@ -10720,6 +10930,22 @@ export const handler = async (event, context) => { }); page = await pages.findOne({ _id: insertion.insertedId }); } + + // 🔔 Tell opted-in devices a page went up. Fire-and-forget so the + // publish never waits on push fan-out; the body stays generic since + // page words are subscriber-only. + broadcastToTopic( + database.db, + "sotce-pages", + { + title: "Sotce Net", + body: "A new page has been written.", + data: { piece: "" }, + ttl: 3600, + }, + shell.log, + ).catch((err) => shell.error("🔔 Page push failed:", err?.message)); + await database.disconnect(); return respond(200, { page }); } else { @@ -11008,6 +11234,21 @@ export const handler = async (event, context) => { // NOTE: No separate page is created — answered questions live only in // sotce-asks and get swizzled into the feed client-side alongside diary pages. + // 🔔 Tell the asker their question was answered. Their push registration + // is stored under the "sotce-"-prefixed sub (see register-push-token). + sendToUser( + database.db, + "sotce-" + question.user, + { + title: "Sotce Net", + body: "Your question has been answered.", + data: { piece: "" }, + ttl: 24 * 3600, + }, + {}, + shell.log, + ).catch((err) => shell.error("🔔 Answer push failed:", err?.message)); + await database.disconnect(); shell.log("❓ Question answered:", askId, "by", user.email);