Skip to content

Push notifications#183

Open
encryptedDegen wants to merge 7 commits into
mainfrom
push-notifications
Open

Push notifications#183
encryptedDegen wants to merge 7 commits into
mainfrom
push-notifications

Conversation

@encryptedDegen

@encryptedDegen encryptedDegen commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
grails Ready Ready Preview, Comment Jun 22, 2026 8:49am

Request Review

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds browser push notification support: a service worker push/notificationclick handler, a new src/api/push module for VAPID key fetching and subscription CRUD, a src/lib/push-notifications.ts browser utility layer, and a settings UI toggle. The parsePushResponse helper correctly handles 204 and 404/501 responses, and the service worker correctly normalizes URLs and uses a focus-then-navigate fallback.

  • State inconsistency on disable failure: in the disable path of togglePushNotifications, setBrowserSubscription(null) is called only after both unsubscribeFromBrowserPush and deletePushSubscription succeed; if the backend delete throws, the browser subscription is already gone but the UI toggle stays "enabled" with stale state.
  • Hook size: the useSettings function body is ~246 lines; the self-contained push block (state, effects, query, toggle) should be extracted into a dedicated usePushNotifications hook.
  • Device name length: the userAgent fallback in getDefaultPushDeviceName can produce 100–250 character strings that may overflow a constrained deviceName column.

Confidence Score: 4/5

The core push pipeline is sound, but the disable path in togglePushNotifications leaves the UI toggle stuck in the enabled state when the backend delete call fails after the browser has already unsubscribed.

When a user disables push and the backend DELETE returns an error, setBrowserSubscription(null) is never reached, so the toggle renders as enabled while push is actually broken. This is a present, reproducible state divergence on an error path that is realistic (transient network, expired subscription ID). The fix is a one-line reorder, but without it the feature ships with a confusing failure mode.

src/components/modal/settings/useSettings.ts — specifically the disable branch of togglePushNotifications (lines 101–107)

Important Files Changed

Filename Overview
src/components/modal/settings/useSettings.ts Adds push notification state, query, and toggle logic to the already-large hook; toggle disable path has a state inconsistency bug when the backend delete fails after browser unsubscription succeeds
public/sw.js Adds push and notificationclick handlers with safe payload parsing, URL normalization, and a correct focus-then-navigate pattern
src/api/push/index.ts New API module for VAPID key fetch, subscription CRUD, and response parsing; 204 and 404/501 cases are handled correctly
src/lib/push-notifications.ts Clean browser push utility with good SSR guards; device name falls back to full user-agent string which could overflow a constrained DB column
src/components/modal/settings/settingsModal.tsx Adds push toggle UI with appropriate disabled states and error messages for denied permission, unsupported browser, and unavailable backend

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Settings Toggle
    participant Hook as useSettings
    participant Lib as push-notifications.ts
    participant API as api/push
    participant SW as Service Worker
    participant Browser as Browser PushManager
    participant Backend as Backend API

    Note over UI,Backend: Enable flow
    UI->>Hook: togglePushNotifications()
    Hook->>API: getVapidPublicKey()
    API->>Backend: GET /push/vapid-public-key
    Backend-->>API: "{ publicKey }"
    Hook->>Lib: subscribeToBrowserPush(vapidKey)
    Lib->>Browser: pushManager.subscribe()
    Browser-->>Lib: PushSubscription
    Hook->>API: registerPushSubscription(payload)
    API->>Backend: POST /users/me/push-subscriptions
    Backend-->>API: PushSubscriptionRecord
    Hook->>Hook: setBrowserSubscription + refetch

    Note over SW,Backend: Push delivery
    Backend->>SW: Push event (encrypted payload)
    SW->>SW: parsePushPayload()
    SW->>Browser: showNotification(title, body, data)
    Browser-->>SW: notificationclick
    SW->>SW: openNotificationTarget(url)
    SW->>UI: focus / navigate / openWindow

    Note over UI,Backend: Disable flow
    UI->>Hook: togglePushNotifications()
    Hook->>Lib: unsubscribeFromBrowserPush()
    Lib->>Browser: subscription.unsubscribe()
    Hook->>API: deletePushSubscription(id)
    API->>Backend: DELETE /users/me/push-subscriptions/:id
    Hook->>Hook: setBrowserSubscription(null) + refetch
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Settings Toggle
    participant Hook as useSettings
    participant Lib as push-notifications.ts
    participant API as api/push
    participant SW as Service Worker
    participant Browser as Browser PushManager
    participant Backend as Backend API

    Note over UI,Backend: Enable flow
    UI->>Hook: togglePushNotifications()
    Hook->>API: getVapidPublicKey()
    API->>Backend: GET /push/vapid-public-key
    Backend-->>API: "{ publicKey }"
    Hook->>Lib: subscribeToBrowserPush(vapidKey)
    Lib->>Browser: pushManager.subscribe()
    Browser-->>Lib: PushSubscription
    Hook->>API: registerPushSubscription(payload)
    API->>Backend: POST /users/me/push-subscriptions
    Backend-->>API: PushSubscriptionRecord
    Hook->>Hook: setBrowserSubscription + refetch

    Note over SW,Backend: Push delivery
    Backend->>SW: Push event (encrypted payload)
    SW->>SW: parsePushPayload()
    SW->>Browser: showNotification(title, body, data)
    Browser-->>SW: notificationclick
    SW->>SW: openNotificationTarget(url)
    SW->>UI: focus / navigate / openWindow

    Note over UI,Backend: Disable flow
    UI->>Hook: togglePushNotifications()
    Hook->>Lib: unsubscribeFromBrowserPush()
    Lib->>Browser: subscription.unsubscribe()
    Hook->>API: deletePushSubscription(id)
    API->>Backend: DELETE /users/me/push-subscriptions/:id
    Hook->>Hook: setBrowserSubscription(null) + refetch
Loading

Fix All in Conductor Fix All in Cursor Fix All in Codex Fix All in Claude Code

Reviews (6): Last reviewed commit: "Update src/components/modal/settings/use..." | Re-trigger Greptile

Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread BACKEND_PUSH_NOTIFICATIONS.md Outdated
Comment thread public/sw.js
Comment thread src/api/push/index.ts
@encryptedDegen encryptedDegen changed the title Add backend push notification plan Push notifications Jun 21, 2026
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment thread src/components/modal/settings/useSettings.ts
Comment on lines +85 to +93
useEffect(() => {
if (backendSubscriptionsError && isPushBackendUnavailableError(backendSubscriptionsError)) {
setPushUnavailable(true)
}
}, [backendSubscriptionsError])

const isPushEnabled = useMemo(() => {
if (!browserSubscription || !backendSubscriptions) return false
return backendSubscriptions.some((sub) => sub.endpoint === browserSubscription.endpoint)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Generic query errors silently leave toggle in a misleading state

When getPushSubscriptions fails with a non-PushBackendUnavailableError (e.g. a 500, auth error, or network timeout), backendSubscriptions stays undefined, so isPushEnabled evaluates to false — the toggle appears "off". No pushError or pushUnavailable is set, so the UI shows no indication of failure, and the button remains enabled. A user who already has an active browser subscription then clicks the toggle to "re-enable" it: subscribeToBrowserPush returns the still-registered browser subscription (isExisting: true) and registerPushSubscription re-posts it to the backend, potentially creating a duplicate record. The missing branch is: any error that isn't a PushBackendUnavailableError should also surface to the user via setPushError.

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment on lines +102 to +107
await unsubscribeFromBrowserPush()
if (backendSub) {
await deletePushSubscription(backendSub.id)
}
setBrowserSubscription(null)
await refetchBackendSubscriptions()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale browser subscription state after partial disable failure

setBrowserSubscription(null) is only called after both unsubscribeFromBrowserPush() and deletePushSubscription() succeed. If deletePushSubscription throws, the browser subscription is already gone from the push manager but browserSubscription state still holds the old object and backendSubscriptions hasn't been refetched — so isPushEnabled stays true and the toggle appears enabled despite push being broken. Moving setBrowserSubscription(null) to immediately after unsubscribeFromBrowserPush() returns keeps client state consistent regardless of what the backend call does.

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant