From c185ce4dc9f6aff2e8a7b14c1c99c7e970cb8d57 Mon Sep 17 00:00:00 2001 From: olegberman Date: Wed, 29 Jul 2026 14:21:39 -0400 Subject: [PATCH] Add push notifications and unread counters --- .github/workflows/staging-e2e.yml | 8 +- CHANGELOG.md | 8 + app/app.tsx | 7 + app/components/layout/app-shell.tsx | 4 + app/components/layout/mobile-navigation.tsx | 4 + app/components/layout/sidebar.tsx | 13 +- app/components/layout/top-bar.tsx | 4 + app/features/auth/api.ts | 4 + app/features/inbox/inbox-page.tsx | 6 +- app/features/notifications/api.ts | 25 ++ app/features/notifications/browser.ts | 80 +++++++ .../notifications/notification-settings.tsx | 127 ++++++++++ app/features/notifications/sign-out.ts | 14 ++ app/features/notifications/types.ts | 28 +++ .../notifications/use-notifications.ts | 181 ++++++++++++++ app/features/settings/settings-page.tsx | 8 + app/lib/api-client.ts | 9 +- app/lib/routes.ts | 9 +- docs/deployment.md | 13 +- migrations/0006_push_notifications.sql | 16 ++ package.json | 4 +- pnpm-lock.yaml | 110 +++++++++ scripts/build-pwa.mjs | 69 ++++++ scripts/hqbase/reset-d1.sql | 1 + scripts/release/deploy.mjs | 58 +++-- test/integration/worker/notifications.test.ts | 223 ++++++++++++++++++ test/unit/app/layout/mail-shell.test.tsx | 8 + .../app/messages/conversation-reader.test.tsx | 2 + test/unit/app/notifications/browser.test.ts | 65 +++++ .../notification-settings.test.tsx | 43 ++++ test/unit/app/notifications/sign-out.test.ts | 33 +++ .../settings/settings-presentation.test.tsx | 12 + test/unit/scripts/deploy.test.mjs | 54 ++++- test/unit/scripts/pwa-build.test.mjs | 13 + .../features/notifications/delivery.test.ts | 134 +++++++++++ test/unit/worker/index-notifications.test.ts | 56 +++++ vitest.worker.config.ts | 4 +- worker/email/inbound.ts | 4 +- worker/email/store-email.ts | 14 +- worker/features/notifications/delivery.ts | 97 ++++++++ worker/features/notifications/queries.ts | 111 +++++++++ worker/features/notifications/routes.ts | 67 ++++++ worker/features/notifications/types.ts | 24 ++ worker/index.ts | 12 +- worker/lib/env.ts | 3 + worker/routes/index.ts | 2 + 46 files changed, 1751 insertions(+), 40 deletions(-) create mode 100644 app/features/notifications/api.ts create mode 100644 app/features/notifications/browser.ts create mode 100644 app/features/notifications/notification-settings.tsx create mode 100644 app/features/notifications/sign-out.ts create mode 100644 app/features/notifications/types.ts create mode 100644 app/features/notifications/use-notifications.ts create mode 100644 migrations/0006_push_notifications.sql create mode 100644 test/integration/worker/notifications.test.ts create mode 100644 test/unit/app/notifications/browser.test.ts create mode 100644 test/unit/app/notifications/notification-settings.test.tsx create mode 100644 test/unit/app/notifications/sign-out.test.ts create mode 100644 test/unit/worker/features/notifications/delivery.test.ts create mode 100644 test/unit/worker/index-notifications.test.ts create mode 100644 worker/features/notifications/delivery.ts create mode 100644 worker/features/notifications/queries.ts create mode 100644 worker/features/notifications/routes.ts create mode 100644 worker/features/notifications/types.ts diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index c35b451..2344415 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -70,7 +70,13 @@ jobs: echo "HQBASE_STAGING_AUTH_SECRET=$AUTH_SECRET" >> "$GITHUB_ENV" AUTH_SECRET="$AUTH_SECRET" node --input-type=module -e ' import { writeFileSync } from "node:fs"; - writeFileSync("/tmp/hqbase-e2e-secrets.json", `${JSON.stringify({ BETTER_AUTH_SECRET: process.env.AUTH_SECRET })}\n`, { mode: 0o600 }); + import webpush from "web-push"; + const vapid = webpush.generateVAPIDKeys(); + writeFileSync("/tmp/hqbase-e2e-secrets.json", `${JSON.stringify({ + BETTER_AUTH_SECRET: process.env.AUTH_SECRET, + VAPID_PUBLIC_KEY: vapid.publicKey, + VAPID_PRIVATE_KEY: vapid.privateKey + })}\n`, { mode: 0o600 }); ' pnpm build pnpm exec wrangler d1 migrations apply DB --remote --config "$config" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0030c6d..897447f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.23 + +- Add explicit per-device Web Push notifications for installed iOS, Android, and desktop PWAs. +- Show exact accessible Inbox and Catch-all unread totals in navigation, the document title, and + supported installed-app badges. +- Generate customer-owned VAPID secrets during installation and updates while keeping notification + payloads free of mail metadata. + ## 0.1.22 - Keep the mobile and installed PWA shell below the iPhone Dynamic Island while preserving the diff --git a/app/app.tsx b/app/app.tsx index d34fec9..cf6c45d 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -12,6 +12,7 @@ import type { Mailbox } from "@/features/mailboxes/types"; import { listConversations, listMessages } from "@/features/messages/api"; import { mergeIncomingMessageIds } from "@/features/messages/incoming-sound"; import type { ConversationSummary } from "@/features/messages/types"; +import { useNotifications } from "@/features/notifications/use-notifications"; import { SettingsPage } from "@/features/settings/settings-page"; import { getSetupStatus } from "@/features/setup/api"; import { SetupPage } from "@/features/setup/setup-page"; @@ -91,6 +92,9 @@ export function App(): React.ReactElement { }); setConversations(nextConversations); }, [activeFolder, mailboxId, search, user]); + const notifications = useNotifications(currentUserId, () => { + void reloadConversations(); + }); React.useEffect(() => { void reload(); @@ -184,6 +188,7 @@ export function App(): React.ReactElement { immersiveOnCompact={activeFolder !== "settings" && selectedId !== null} mailboxId={mailboxId} mailboxes={contentMailboxes} + unread={notifications.unread} search={search} user={user} updateInProgress={updateMonitor.progress !== null} @@ -215,6 +220,7 @@ export function App(): React.ReactElement { activeTab={settingsTab} canManage={user.role === "owner" || user.role === "admin"} mailboxes={mailboxes} + notifications={notifications} setup={setup} users={users} onRefresh={() => void reload()} @@ -231,6 +237,7 @@ export function App(): React.ReactElement { mailboxes={contentMailboxes} selectedId={selectedId} onRefresh={() => void reloadConversations()} + onUnreadChange={notifications.refresh} onMessageRouteChange={(folder, messageId) => navigate({ kind: "mail", folder, messageId }) } diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index f7d24b4..1e1f1a9 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -1,6 +1,7 @@ import type * as React from "react"; import type { CurrentUser } from "@/features/auth/types"; import type { Mailbox } from "@/features/mailboxes/types"; +import type { UnreadCounts } from "@/features/notifications/types"; import type { UpdateStatus } from "@/features/updates/types"; import { UpdateBanner } from "@/features/updates/update-banner"; import { cn } from "@/lib/cn"; @@ -18,6 +19,7 @@ type AppShellProps = { user: CurrentUser; updateInProgress: boolean; updateStatus: UpdateStatus | null; + unread: UnreadCounts; onCompose: () => void; onFolderChange: (folder: FolderId) => void; onMailboxChange: (mailboxId: string) => void; @@ -31,6 +33,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
void; onSignedOut: () => void; }; export function MobileNavigation({ activeFolder, + unread, user, onFolderChange, onSignedOut @@ -61,6 +64,7 @@ export function MobileNavigation({ Navigation void; onSignedOut: () => void; variant?: "desktop" | "drawer"; @@ -32,6 +34,7 @@ const icons: Record = { export function Sidebar({ activeFolder, drawerAction, + unread, user, onFolderChange, onSignedOut, @@ -55,6 +58,8 @@ export function Sidebar({