feat: PWA support — installable app, Web Push notifications, app badge

Phase 1: Installable PWA
- Add web app manifest with multi-size icons (192, 512, maskable, apple-touch)
- Add PWA meta tags to all entry points (shell.ts, canvas.html, index.html, create-space.html)
- Register service worker on all pages (previously only canvas.html)
- Add manifest.json to precache core list
- Capture beforeinstallprompt for custom install UX

Phase 2: Web Push Notifications
- Add web-push dependency + push_subscriptions DB table
- VAPID key endpoint, subscribe/unsubscribe routes in notification-routes.ts
- Web Push delivery in notify() with auto-cleanup of expired subscriptions
- SW push + notificationclick event handlers
- Client push subscription flow in notification bell component

Phase 3: Install UX Polish
- App badge (setAppBadge/clearAppBadge) on unread count changes
- "Enable push" button in notification panel header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 17:35:30 -07:00
parent 1cd8225680
commit a7063d24f5
19 changed files with 492 additions and 5 deletions

View File

@ -40,12 +40,14 @@
"perfect-freehand": "^1.2.2", "perfect-freehand": "^1.2.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"web-push": "^3.6.7",
"yaml": "^2.8.2", "yaml": "^2.8.2",
}, },
"devDependencies": { "devDependencies": {
"@types/mailparser": "^3.4.0", "@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/web-push": "^3.6.4",
"@x402/fetch": "^2.5.0", "@x402/fetch": "^2.5.0",
"bun-types": "^1.1.38", "bun-types": "^1.1.38",
"typescript": "^5.7.2", "typescript": "^5.7.2",
@ -561,6 +563,8 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="], "@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="],
"@x402/evm": ["@x402/evm@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "@x402/extensions": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-MBSTQZwLobMVcmYO7itOMJRkxfHstsDyr7F94o9Rk/Oinz0kjvCe4DFgZmFXyz3nQUgQFmDVgTK5KIzfYR5uIA=="], "@x402/evm": ["@x402/evm@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "@x402/extensions": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-MBSTQZwLobMVcmYO7itOMJRkxfHstsDyr7F94o9Rk/Oinz0kjvCe4DFgZmFXyz3nQUgQFmDVgTK5KIzfYR5uIA=="],
@ -587,6 +591,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
@ -603,6 +609,8 @@
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
@ -741,6 +749,8 @@
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
@ -815,8 +825,12 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -1013,6 +1027,8 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],

View File

@ -20,8 +20,8 @@
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
"@noble/curves": "^1.8.0", "@noble/curves": "^1.8.0",
"@openfort/openfort-node": "^0.7.0",
"@noble/hashes": "^1.7.0", "@noble/hashes": "^1.7.0",
"@openfort/openfort-node": "^0.7.0",
"@tiptap/core": "^3.20.0", "@tiptap/core": "^3.20.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0", "@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-image": "^3.20.0", "@tiptap/extension-image": "^3.20.0",
@ -48,12 +48,14 @@
"perfect-freehand": "^1.2.2", "perfect-freehand": "^1.2.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"web-push": "^3.6.7",
"yaml": "^2.8.2" "yaml": "^2.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/mailparser": "^3.4.0", "@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/web-push": "^3.6.4",
"@x402/fetch": "^2.5.0", "@x402/fetch": "^2.5.0",
"bun-types": "^1.1.38", "bun-types": "^1.1.38",
"typescript": "^5.7.2", "typescript": "^5.7.2",

38
scripts/generate-icons.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* One-off script to generate PWA icons from logo.png.
* Run: bun run scripts/generate-icons.ts
*/
import sharp from "sharp";
import { resolve } from "node:path";
const src = resolve(import.meta.dir, "../website/public/logo.png");
const outDir = resolve(import.meta.dir, "../website/public/icons");
// 192x192
await sharp(src).resize(192, 192).png().toFile(resolve(outDir, "icon-192.png"));
console.log("✓ icon-192.png");
// 512x512
await sharp(src).resize(512, 512).png().toFile(resolve(outDir, "icon-512.png"));
console.log("✓ icon-512.png");
// 180x180 apple-touch-icon
await sharp(src).resize(180, 180).png().toFile(resolve(outDir, "apple-touch-icon.png"));
console.log("✓ apple-touch-icon.png");
// Maskable 512x512: logo centered in inner 80% (410px) on #0f172a background
const logoForMask = await sharp(src).resize(410, 410).png().toBuffer();
await sharp({
create: {
width: 512,
height: 512,
channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 }, // #0f172a
},
})
.composite([{ input: logoForMask, gravity: "centre" }])
.png()
.toFile(resolve(outDir, "icon-maskable-512.png"));
console.log("✓ icon-maskable-512.png");
console.log("Done! Icons written to website/public/icons/");

View File

@ -17,6 +17,8 @@ import {
dismissNotification, dismissNotification,
getNotificationPreferences, getNotificationPreferences,
upsertNotificationPreferences, upsertNotificationPreferences,
savePushSubscription,
deletePushSubscriptionByEndpoint,
} from "../src/encryptid/db"; } from "../src/encryptid/db";
export const notificationRouter = new Hono(); export const notificationRouter = new Hono();
@ -139,3 +141,49 @@ notificationRouter.patch("/preferences", async (c) => {
const prefs = await upsertNotificationPreferences(claims.sub, body); const prefs = await upsertNotificationPreferences(claims.sub, body);
return c.json({ preferences: prefs }); return c.json({ preferences: prefs });
}); });
// ============================================================================
// WEB PUSH ENDPOINTS
// ============================================================================
// ── GET /push/vapid-public-key — Return public VAPID key (no auth) ──
notificationRouter.get("/push/vapid-public-key", (c) => {
const key = process.env.VAPID_PUBLIC_KEY;
if (!key) return c.json({ error: "Push not configured" }, 503);
return c.json({ publicKey: key });
});
// ── POST /push/subscribe — Save push subscription (auth required) ──
notificationRouter.post("/push/subscribe", async (c) => {
const claims = await requireAuth(c.req.raw);
if (!claims) return c.json({ error: "Authentication required" }, 401);
const body = await c.req.json();
const { endpoint, keys } = body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return c.json({ error: "Invalid subscription object" }, 400);
}
await savePushSubscription({
id: crypto.randomUUID(),
userDid: claims.sub,
endpoint,
keyP256dh: keys.p256dh,
keyAuth: keys.auth,
userAgent: c.req.header("user-agent"),
});
return c.json({ ok: true });
});
// ── POST /push/unsubscribe — Remove push subscription (auth required) ──
notificationRouter.post("/push/unsubscribe", async (c) => {
const claims = await requireAuth(c.req.raw);
if (!claims) return c.json({ error: "Authentication required" }, 401);
const body = await c.req.json();
if (!body.endpoint) return c.json({ error: "endpoint required" }, 400);
await deletePushSubscriptionByEndpoint(body.endpoint);
return c.json({ ok: true });
});

View File

@ -7,14 +7,31 @@
*/ */
import type { ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
import webpush from "web-push";
import { import {
createNotification, createNotification,
getUnreadCount, getUnreadCount,
markNotificationDelivered, markNotificationDelivered,
listSpaceMembers, listSpaceMembers,
getUserPushSubscriptions,
deletePushSubscriptionByEndpoint,
updatePushSubscriptionLastUsed,
getNotificationPreferences,
type StoredNotification, type StoredNotification,
} from "../src/encryptid/db"; } from "../src/encryptid/db";
// ── VAPID setup ──
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@rspace.online";
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
console.log("[push] VAPID configured");
} else {
console.warn("[push] VAPID keys not set — Web Push disabled");
}
// ============================================================================ // ============================================================================
// TYPES // TYPES
// ============================================================================ // ============================================================================
@ -133,9 +150,62 @@ export async function notify(opts: NotifyOptions): Promise<StoredNotification> {
} }
} }
// 3. Attempt Web Push delivery (non-blocking)
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
sendWebPush(stored, opts).catch(() => {});
}
return stored; return stored;
} }
/** Send Web Push to all of a user's subscriptions. */
async function sendWebPush(stored: StoredNotification, opts: NotifyOptions): Promise<void> {
// Check if user has push enabled
const prefs = await getNotificationPreferences(opts.userDid);
if (prefs && !prefs.pushEnabled) return;
const subs = await getUserPushSubscriptions(opts.userDid);
if (subs.length === 0) return;
const pushPayload = JSON.stringify({
title: stored.title,
body: stored.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
tag: `${stored.category}-${stored.eventType}`,
data: {
url: stored.actionUrl || "/",
notificationId: stored.id,
},
});
let anyDelivered = false;
await Promise.allSettled(
subs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.keyP256dh, auth: sub.keyAuth },
},
pushPayload,
);
anyDelivered = true;
updatePushSubscriptionLastUsed(sub.id).catch(() => {});
} catch (err: any) {
// 404/410 = subscription expired, clean up
if (err?.statusCode === 404 || err?.statusCode === 410) {
await deletePushSubscriptionByEndpoint(sub.endpoint);
}
}
}),
);
if (anyDelivered) {
await markNotificationDelivered(stored.id, 'push');
}
}
// ============================================================================ // ============================================================================
// CONVENIENCE: NOTIFY SPACE ADMINS/MODS // CONVENIENCE: NOTIFY SPACE ADMINS/MODS
// ============================================================================ // ============================================================================

View File

@ -104,6 +104,12 @@ export function renderShell(opts: ShellOptions): string {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
@ -155,6 +161,16 @@ export function renderShell(opts: ShellOptions): string {
<script type="module"> <script type="module">
import '/shell.js'; import '/shell.js';
// ── Service worker registration ──
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
// ── Install prompt capture ──
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
window.dispatchEvent(new CustomEvent("rspace-install-available"));
});
// ── Settings panel toggle ── // ── Settings panel toggle ──
document.getElementById('settings-btn')?.addEventListener('click', () => { document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings'); const panel = document.querySelector('rstack-space-settings');
@ -990,6 +1006,12 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(mod.name)} rSpace</title> <title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
@ -1015,6 +1037,9 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
${bodyContent} ${bodyContent}
<script type="module"> <script type="module">
import '/shell.js'; import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() { function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn'); var btn = document.querySelector('.rstack-header__demo-btn');
@ -1323,6 +1348,12 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title> <title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
@ -1349,6 +1380,9 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
${bodyContent} ${bodyContent}
<script type="module"> <script type="module">
import '/shell.js'; import '/shell.js';
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() { function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn'); var btn = document.querySelector('.rstack-header__demo-btn');

View File

@ -30,6 +30,8 @@ export class RStackNotificationBell extends HTMLElement {
#pollTimer: ReturnType<typeof setInterval> | null = null; #pollTimer: ReturnType<typeof setInterval> | null = null;
#open = false; #open = false;
#loading = false; #loading = false;
#pushSupported = false;
#pushSubscribed = false;
constructor() { constructor() {
super(); super();
@ -46,6 +48,10 @@ export class RStackNotificationBell extends HTMLElement {
// Re-render on auth change // Re-render on auth change
document.addEventListener("auth-change", this.#onAuthChange); document.addEventListener("auth-change", this.#onAuthChange);
// Check push support
this.#pushSupported = "PushManager" in window && "serviceWorker" in navigator;
if (this.#pushSupported) this.#checkPushState();
} }
disconnectedCallback() { disconnectedCallback() {
@ -64,6 +70,10 @@ export class RStackNotificationBell extends HTMLElement {
if (!this.#pollTimer) { if (!this.#pollTimer) {
this.#pollTimer = setInterval(() => this.#fetchCount(), POLL_INTERVAL); this.#pollTimer = setInterval(() => this.#fetchCount(), POLL_INTERVAL);
} }
// Auto-subscribe to push if permission already granted
if (this.#pushSupported && Notification.permission === "granted" && !this.#pushSubscribed) {
this.#subscribePush();
}
}; };
#onWsNotification = (e: CustomEvent) => { #onWsNotification = (e: CustomEvent) => {
@ -71,6 +81,7 @@ export class RStackNotificationBell extends HTMLElement {
if (!data?.notification) return; if (!data?.notification) return;
this.#unreadCount = data.unreadCount ?? this.#unreadCount + 1; this.#unreadCount = data.unreadCount ?? this.#unreadCount + 1;
this.#updateAppBadge(this.#unreadCount);
// Prepend to list if panel is loaded // Prepend to list if panel is loaded
this.#notifications.unshift({ this.#notifications.unshift({
...data.notification, ...data.notification,
@ -99,6 +110,7 @@ export class RStackNotificationBell extends HTMLElement {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
this.#unreadCount = data.unreadCount || 0; this.#unreadCount = data.unreadCount || 0;
this.#updateAppBadge(this.#unreadCount);
this.#render(); this.#render();
} else if (res.status === 401) { } else if (res.status === 401) {
// Token rejected — stop polling until auth changes // Token rejected — stop polling until auth changes
@ -173,6 +185,7 @@ export class RStackNotificationBell extends HTMLElement {
this.#notifications.forEach(n => n.read = true); this.#notifications.forEach(n => n.read = true);
this.#unreadCount = 0; this.#unreadCount = 0;
this.#updateAppBadge(0);
this.#render(); this.#render();
} }
@ -198,6 +211,77 @@ export class RStackNotificationBell extends HTMLElement {
} }
} }
// ── Push subscription ──
async #checkPushState() {
if (!this.#pushSupported) return;
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
this.#pushSubscribed = !!sub;
} catch {
this.#pushSubscribed = false;
}
}
async #subscribePush() {
if (!this.#pushSupported) return;
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
// Fetch VAPID public key
const keyRes = await fetch("/api/notifications/push/vapid-public-key");
if (!keyRes.ok) return;
const { publicKey } = await keyRes.json();
if (!publicKey) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.#urlBase64ToUint8Array(publicKey) as BufferSource,
});
const token = this.#getToken();
if (!token) return;
await fetch("/api/notifications/push/subscribe", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(sub.toJSON()),
});
this.#pushSubscribed = true;
this.#render();
} catch (err) {
console.warn("[push] subscribe failed:", err);
}
}
#urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
// ── App badge ──
#updateAppBadge(count: number) {
if ("setAppBadge" in navigator) {
if (count > 0) {
(navigator as any).setAppBadge(count);
} else {
(navigator as any).clearAppBadge();
}
}
}
#togglePanel() { #togglePanel() {
this.#open = !this.#open; this.#open = !this.#open;
if (this.#open && this.#notifications.length === 0) { if (this.#open && this.#notifications.length === 0) {
@ -240,10 +324,16 @@ export class RStackNotificationBell extends HTMLElement {
let panelHTML = ""; let panelHTML = "";
if (this.#open) { if (this.#open) {
const pushBtn = this.#pushSupported && !this.#pushSubscribed && Notification.permission !== "denied"
? `<button class="push-btn" data-action="enable-push">Enable push</button>`
: "";
const header = ` const header = `
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Notifications</span> <span class="panel-title">Notifications</span>
${this.#unreadCount > 0 ? `<button class="mark-all-btn" data-action="mark-all-read">Mark all read</button>` : ""} <div class="panel-actions">
${pushBtn}
${this.#unreadCount > 0 ? `<button class="mark-all-btn" data-action="mark-all-read">Mark all read</button>` : ""}
</div>
</div> </div>
`; `;
@ -311,6 +401,12 @@ export class RStackNotificationBell extends HTMLElement {
this.#markAllRead(); this.#markAllRead();
}); });
// Enable push
this.#shadow.querySelector('[data-action="enable-push"]')?.addEventListener("click", (e) => {
e.stopPropagation();
this.#subscribePush();
});
// Notification item clicks (mark read + navigate) // Notification item clicks (mark read + navigate)
this.#shadow.querySelectorAll(".notif-item").forEach((el) => { this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
const id = (el as HTMLElement).dataset.id!; const id = (el as HTMLElement).dataset.id!;
@ -419,7 +515,13 @@ const STYLES = `
color: var(--rs-text-primary, #e2e8f0); color: var(--rs-text-primary, #e2e8f0);
} }
.mark-all-btn { .panel-actions {
display: flex;
gap: 4px;
align-items: center;
}
.mark-all-btn, .push-btn {
background: none; background: none;
border: none; border: none;
color: var(--rs-accent, #06b6d4); color: var(--rs-accent, #06b6d4);
@ -429,10 +531,15 @@ const STYLES = `
border-radius: 4px; border-radius: 4px;
transition: background 0.15s; transition: background 0.15s;
} }
.mark-all-btn:hover { .mark-all-btn:hover, .push-btn:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.05)); background: var(--rs-bg-hover, rgba(255,255,255,0.05));
} }
.push-btn {
color: var(--rs-text-muted, #94a3b8);
border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
}
.panel-empty { .panel-empty {
padding: 32px 16px; padding: 32px 16px;
text-align: center; text-align: center;

View File

@ -1657,4 +1657,70 @@ export async function cleanExpiredIdentityInvites(): Promise<number> {
return result.count; return result.count;
} }
// ============================================================================
// PUSH SUBSCRIPTIONS
// ============================================================================
export interface StoredPushSubscription {
id: string;
userDid: string;
endpoint: string;
keyP256dh: string;
keyAuth: string;
userAgent: string | null;
createdAt: string;
lastUsed: string | null;
}
export async function savePushSubscription(sub: {
id: string;
userDid: string;
endpoint: string;
keyP256dh: string;
keyAuth: string;
userAgent?: string;
}): Promise<void> {
await sql`
INSERT INTO push_subscriptions (id, user_did, endpoint, key_p256dh, key_auth, user_agent)
VALUES (${sub.id}, ${sub.userDid}, ${sub.endpoint}, ${sub.keyP256dh}, ${sub.keyAuth}, ${sub.userAgent ?? null})
ON CONFLICT (user_did, endpoint) DO UPDATE SET
key_p256dh = EXCLUDED.key_p256dh,
key_auth = EXCLUDED.key_auth,
user_agent = EXCLUDED.user_agent
`;
}
export async function getUserPushSubscriptions(userDid: string): Promise<StoredPushSubscription[]> {
const rows = await sql`
SELECT id, user_did, endpoint, key_p256dh, key_auth, user_agent, created_at, last_used
FROM push_subscriptions
WHERE user_did = ${userDid}
`;
return rows.map(r => ({
id: r.id,
userDid: r.user_did,
endpoint: r.endpoint,
keyP256dh: r.key_p256dh,
keyAuth: r.key_auth,
userAgent: r.user_agent,
createdAt: r.created_at,
lastUsed: r.last_used,
}));
}
export async function deletePushSubscription(id: string, userDid: string): Promise<boolean> {
const result = await sql`
DELETE FROM push_subscriptions WHERE id = ${id} AND user_did = ${userDid}
`;
return result.count > 0;
}
export async function deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
await sql`DELETE FROM push_subscriptions WHERE endpoint = ${endpoint}`;
}
export async function updatePushSubscriptionLastUsed(id: string): Promise<void> {
await sql`UPDATE push_subscriptions SET last_used = NOW() WHERE id = ${id}`;
}
export { sql }; export { sql };

View File

@ -342,6 +342,24 @@ CREATE TABLE IF NOT EXISTS linked_wallets (
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id); CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash); CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash);
-- ============================================================================
-- PUSH SUBSCRIPTIONS (Web Push notifications)
-- ============================================================================
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_did TEXT NOT NULL,
endpoint TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
key_auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used TIMESTAMPTZ,
UNIQUE (user_did, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_did);
-- Prevent duplicate wallet links per user (application-level check + DB enforcement) -- Prevent duplicate wallet links per user (application-level check + DB enforcement)
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE linked_wallets ADD CONSTRAINT linked_wallets_user_address_unique ALTER TABLE linked_wallets ADD CONSTRAINT linked_wallets_user_address_unique

View File

@ -1096,7 +1096,8 @@ export default defineConfig({
f === "/shell.js" || f === "/shell.js" ||
f === "/shell.css" || f === "/shell.css" ||
f === "/theme.css" || f === "/theme.css" ||
f === "/favicon.png" f === "/favicon.png" ||
f === "/manifest.json"
).map((f) => hashes[f] ? `${f}?v=${hashes[f]}` : f); ).map((f) => hashes[f] ? `${f}?v=${hashes[f]}` : f);
// Ensure root URL is present // Ensure root URL is present
if (!core.some((f) => f === "/" || f.startsWith("/?v="))) core.unshift("/"); if (!core.some((f) => f === "/" || f.startsWith("/?v="))) core.unshift("/");

View File

@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>rSpace Canvas</title> <title>rSpace Canvas</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1" /> <link rel="stylesheet" href="/theme.css?v=1" />

View File

@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Create a Space — rSpace</title> <title>Create a Space — rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
@ -291,6 +297,9 @@
</div> </div>
<script type="module"> <script type="module">
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
import { RStackIdentity } from "@shared/components/rstack-identity"; import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher"; import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";

View File

@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f0" media="(prefers-color-scheme: light)">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>(you)rSpace — Collaborative Community Spaces</title> <title>(you)rSpace — Collaborative Community Spaces</title>
<style> <style>
* { * {
@ -515,6 +521,9 @@
</div> </div>
<script type="module"> <script type="module">
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
import { RStackIdentity, isAuthenticated, getAccessToken } from "@shared/components/rstack-identity"; import { RStackIdentity, isAuthenticated, getAccessToken } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher"; import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View File

@ -0,0 +1,20 @@
{
"name": "(you)rSpace",
"short_name": "rSpace",
"description": "Local-first collaborative community spaces",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#0f172a",
"background_color": "#0f172a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
],
"shortcuts": [
{ "name": "Create Space", "url": "/create-space", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] }
]
}

View File

@ -220,6 +220,49 @@ self.addEventListener("fetch", (event) => {
); );
}); });
// ============================================================================
// WEB PUSH HANDLERS
// ============================================================================
self.addEventListener("push", (event) => {
if (!event.data) return;
let payload: { title: string; body?: string; icon?: string; badge?: string; tag?: string; data?: any };
try {
payload = event.data.json();
} catch {
payload = { title: event.data.text() || "rSpace" };
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon || "/icons/icon-192.png",
badge: payload.badge || "/icons/icon-192.png",
tag: payload.tag,
data: payload.data,
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
// Focus existing tab if found
for (const client of clients) {
if (new URL(client.url).pathname === url && "focus" in client) {
return client.focus();
}
}
// Otherwise open new window
return self.clients.openWindow(url);
}),
);
});
/** Minimal offline fallback page when nothing is cached. */ /** Minimal offline fallback page when nothing is cached. */
function offlineFallbackPage(): Response { function offlineFallbackPage(): Response {
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>