Skip to content

Commit 21c3c94

Browse files
committed
fix (notifications): notifications popup
feat (pwa): push notifications config
1 parent 3914766 commit 21c3c94

19 files changed

Lines changed: 253 additions & 49 deletions

src/client/.env.selfhost.template

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,7 @@ AUTH0_SCOPE=""
3535
# If you want to enable product analytics, provide your PostHog project key.
3636
NEXT_PUBLIC_POSTHOG_KEY=
3737
# If using a self-hosted PostHog instance, provide the host URL. Otherwise, leave it empty to default to PostHog's US cloud.
38-
NEXT_PUBLIC_POSTHOG_HOST=
38+
NEXT_PUBLIC_POSTHOG_HOST=
39+
# --- PWA Push Notifications (Optional) ---
40+
# Generate VAPID keys using `npx web-push generate-vapid-keys` and add the public key here.
41+
# This MUST match the public key derived from VAPID_PRIVATE_KEY in the server's .env.

src/client/.env.template

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ AUTH0_SCOPE='openid profile email offline_access read:profile write:profile read
1818
# If you want to enable product analytics, provide your PostHog project key.
1919
NEXT_PUBLIC_POSTHOG_KEY=
2020
# If using a self-hosted PostHog instance, provide the host URL. Otherwise, leave it empty to default to PostHog's US cloud.
21-
NEXT_PUBLIC_POSTHOG_HOST=
21+
NEXT_PUBLIC_POSTHOG_HOST=
22+
# --- PWA Push Notifications (Optional) ---
23+
# Generate VAPID keys using `npx web-push generate-vapid-keys` and add the public key here.
24+
NEXT_PUBLIC_VAPID_PUBLIC_KEY=

src/client/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ next-env.d.ts
4141
/public/sw.js
4242
/public/workbox-*.js
4343

44-
# End of https://www.toptal.com/developers/gitignore/api/nextjs
44+
# End of https://www.toptal.com/developers/gitignore/api/nextjs
45+
certificates

src/client/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ ARG AUTH0_AUDIENCE
2525
ARG AUTH0_SCOPE
2626
ARG NEXT_PUBLIC_POSTHOG_KEY
2727
ARG NEXT_PUBLIC_POSTHOG_HOST
28+
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
2829

2930
# Set them as environment variables for the build process
3031
ENV NEXT_PUBLIC_APP_SERVER_URL=$NEXT_PUBLIC_APP_SERVER_URL
@@ -41,6 +42,7 @@ ENV AUTH0_AUDIENCE=$AUTH0_AUDIENCE
4142
ENV AUTH0_SCOPE=$AUTH0_SCOPE
4243
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
4344
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
45+
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY
4446
# --------------------------
4547

4648
# Copy package.json and lock file to leverage Docker cache

src/client/app/layout.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export default function RootLayout({ children }) {
6363
href="/favicon-16x16.png"
6464
/>
6565
<meta name="theme-color" content="#F1A21D" />
66-
<link rel="manifest" href="/manifest.json" />
6766
</head>
6867
<body className="font-sans">
6968
<Auth0Provider>

src/client/app/manifest.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export default function manifest() {
2+
return {
3+
name: "Sentient",
4+
short_name: "Sentient",
5+
description: "Your autopilot for productivity",
6+
start_url: "/",
7+
display: "standalone",
8+
background_color: "#000000",
9+
theme_color: "#F1A21D",
10+
icons: [
11+
{
12+
src: "/android-chrome-192x192.png",
13+
sizes: "192x192",
14+
type: "image/png"
15+
},
16+
{
17+
src: "/android-chrome-512x512.png",
18+
sizes: "512x512",
19+
type: "image/png"
20+
},
21+
{
22+
src: "/maskable-icon-512x512.png",
23+
sizes: "512x512",
24+
type: "image/png",
25+
purpose: "any maskable"
26+
}
27+
]
28+
}
29+
}

src/client/components/LayoutWrapper.js

Lines changed: 116 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,25 @@ import React, { useState, useEffect, useCallback, useRef } from "react"
33
import { usePathname, useRouter } from "next/navigation"
44
import { AnimatePresence } from "framer-motion"
55
import NotificationsOverlay from "@components/NotificationsOverlay"
6-
import { IconMenu2, IconLoader } from "@tabler/icons-react"
6+
import { IconMenu2, IconLoader, IconX } from "@tabler/icons-react"
77
import Sidebar from "@components/Sidebar"
88
import CommandPalette from "./CommandPallete"
99
import GlobalSearch from "./GlobalSearch"
1010
import { useGlobalShortcuts } from "@hooks/useGlobalShortcuts"
1111
import { cn } from "@utils/cn"
1212
import toast from "react-hot-toast"
1313

14+
// Helper function to convert VAPID key
15+
function urlBase64ToUint8Array(base64String) {
16+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
17+
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
18+
const rawData = window.atob(base64);
19+
const outputArray = new Uint8Array(rawData.length);
20+
for (let i = 0; i < rawData.length; ++i) {
21+
outputArray[i] = rawData.charCodeAt(i);
22+
}
23+
return outputArray;
24+
}
1425
export default function LayoutWrapper({ children }) {
1526
const [isNotificationsOpen, setNotificationsOpen] = useState(false)
1627
const [isSearchOpen, setSearchOpen] = useState(false)
@@ -99,7 +110,36 @@ export default function LayoutWrapper({ children }) {
99110
)
100111
ws.onmessage = (event) => {
101112
const data = JSON.parse(event.data)
102-
if (data.type === "task_progress_update") {
113+
if (data.type === "new_notification") {
114+
setUnreadCount((prev) => prev + 1)
115+
toast(
116+
(t) => (
117+
<div className="flex items-center gap-3">
118+
<span className="flex-1">
119+
{data.notification.message}
120+
</span>
121+
<button
122+
onClick={() => {
123+
handleNotificationsOpen()
124+
toast.dismiss(t.id)
125+
}}
126+
className="py-1 px-3 rounded-md bg-brand-orange text-black text-sm font-semibold"
127+
>
128+
View
129+
</button>
130+
<button
131+
onClick={() => toast.dismiss(t.id)}
132+
className="p-1.5 rounded-full hover:bg-neutral-700"
133+
>
134+
<IconX size={16} />
135+
</button>
136+
</div>
137+
),
138+
{
139+
duration: 6000
140+
}
141+
)
142+
} else if (data.type === "task_progress_update") {
103143
// Dispatch a custom event that the tasks page can listen for
104144
window.dispatchEvent(
105145
new CustomEvent("taskProgressUpdate", {
@@ -146,6 +186,33 @@ export default function LayoutWrapper({ children }) {
146186
setCommandPaletteOpen((prev) => !prev)
147187
)
148188

189+
// PWA Update Handler
190+
191+
useEffect(() => {
192+
// This effect runs only on the client side, after the component mounts.
193+
if (
194+
"serviceWorker" in navigator &&
195+
process.env.NODE_ENV !== "development"
196+
) {
197+
// The 'load' event ensures that SW registration doesn't delay page rendering.
198+
window.addEventListener("load", function () {
199+
navigator.serviceWorker.register("/sw.js").then(
200+
function (registration) {
201+
console.log(
202+
"ServiceWorker registration successful with scope: ",
203+
registration.scope
204+
)
205+
},
206+
function (err) {
207+
console.log("ServiceWorker registration failed: ", err)
208+
}
209+
)
210+
})
211+
}
212+
}, [])
213+
214+
// Removed duplicate subscribeToPushNotifications declaration
215+
149216
// PWA Update Handler
150217
useEffect(() => {
151218
if (
@@ -221,16 +288,54 @@ export default function LayoutWrapper({ children }) {
221288
}
222289
}, [])
223290

224-
useEffect(() => {
225-
const handleEscape = (e) => {
226-
if (e.key === "Escape") {
227-
if (isNotificationsOpen) setNotificationsOpen(false)
228-
if (isCommandPaletteOpen) setCommandPaletteOpen(false)
291+
const subscribeToPushNotifications = useCallback(async () => {
292+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
293+
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) {
294+
console.warn("VAPID public key not configured. Skipping push subscription.");
295+
return;
296+
}
297+
console.log("VAPID Public Key is configured. Proceeding with push subscription.");
298+
299+
try {
300+
const registration = await navigator.serviceWorker.ready;
301+
console.log("Service Worker is ready:", registration);
302+
303+
let subscription = await registration.pushManager.getSubscription();
304+
console.log("Existing subscription:", subscription);
305+
306+
if (subscription === null) {
307+
console.log("No existing subscription found. Requesting permission...");
308+
const permission = await window.Notification.requestPermission();
309+
console.log("Notification permission status:", permission);
310+
311+
if (permission !== "granted") return;
312+
313+
console.log("Permission granted. Subscribing to push manager...");
314+
subscription = await registration.pushManager.subscribe({
315+
userVisibleOnly: true,
316+
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
317+
});
318+
console.log("New subscription created:", subscription);
319+
320+
const response = await fetch("/api/notifications/subscribe", {
321+
method: "POST",
322+
headers: { "Content-Type": "application/json" },
323+
body: JSON.stringify(subscription),
324+
});
325+
326+
if (!response.ok) {
327+
throw new Error("Failed to send subscription to server.");
328+
}
329+
console.log("Subscription successfully sent to server.");
229330
}
331+
} catch (error) {
332+
console.error("Error during push notification subscription:", error);
230333
}
231-
window.addEventListener("keydown", handleEscape)
232-
return () => window.removeEventListener("keydown", handleEscape)
233-
}, [isNotificationsOpen, isCommandPaletteOpen])
334+
}, []);
335+
336+
useEffect(() => {
337+
if (showNav && userDetails?.sub) subscribeToPushNotifications()
338+
}, [showNav, userDetails, subscribeToPushNotifications])
234339

235340
if (isLoading) {
236341
return (
@@ -301,3 +406,4 @@ export default function LayoutWrapper({ children }) {
301406
</>
302407
)
303408
}
409+

src/client/docker-compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ services:
2020
- AUTH0_SCOPE=${AUTH0_SCOPE}
2121
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
2222
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
23+
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
2324
container_name: sentient-client
2425
restart: unless-stopped
2526
ports:

src/client/middleware.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ export const config = {
4040
* - api (API routes)
4141
* - PWA files (manifest, icons, service worker, workbox)
4242
*/
43-
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|api|manifest.json|sw.js|workbox-.*\\.js$|.*\\.png$).*)"
43+
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|api|manifest.json|manifest.webmanifest|sw.js|workbox-.*\\.js$|.*\\.png$).*)"
4444
]
4545
}

src/client/next.config.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ const withPWA = nextPWA({
1919
) {
2020
return true
2121
}
22-
if (process.env.NODE_ENV == "development" && !asset.name.startsWith("static/runtime/")) {
22+
if (
23+
process.env.NODE_ENV == "development" &&
24+
!asset.name.startsWith("static/runtime/")
25+
) {
2326
return true
2427
}
2528
return false
@@ -102,4 +105,4 @@ if (process.env.NODE_ENV === "production") {
102105
nextConfig.output = "standalone"
103106
}
104107

105-
export default withPWA(nextConfig)
108+
export default nextConfig

0 commit comments

Comments
 (0)