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:
parent
1cd8225680
commit
a7063d24f5
16
bun.lock
16
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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/");
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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("/");
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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" }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue