From 56315d1189505d0c73171457befe91b1d014f9ef Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:04:18 +0700 Subject: [PATCH] fix: ask for notifications in the extension too and use the extension notification API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix notifications in the extension and make the setting clear. **Why 0.2.0 never asked in the extension:** the manifest's `notifications` permission makes Chrome grant it up front, so the browser has nothing to ask and the first-run banner (which waited for a browser "default" state) never appeared. - **App-level on/off choice.** Notifications now have their own setting on top of the browser permission. The first-run banner ("Get a notification when a focus session or break ends?" → **Turn on**) appears on first open in both the web app and the extension. - **Sample on turn-on.** Turning them on sends a sample notification and shows a note: if it never arrived, allow notifications for the browser in system settings and turn off Do Not Disturb / Focus — the usual reason a granted notification is silently dropped. - **Extension uses `chrome.notifications`.** Inside the extension, notifications go through the extension API (with the extension's icon); clicking one focuses the timer tab and window. The web app keeps the Notification API. - **Menu switch.** "Notifications on · test" is replaced by a plain switch: *Notifications: On / Off / Blocked*. Blocked explains how to allow them in site settings. - An earlier "Not now" dismissal is honoured as Off. ## Testing - Lint, typecheck, unit tests and build pass. - Web (stubbed permission already granted): banner asks on first open; Turn on sends the sample and shows the note; the menu switch flips On/Off and stores the choice. - Extension page (stubbed `chrome` API): banner asks; Turn on calls `chrome.notifications.create` with the sample and the extension icon; a 1-minute session ending sends "Focus session complete" the same way, with no web notification. --- README.md | 2 +- src/components/focus/notifications.ts | 121 ++++++++++++++++---- src/components/shell/NotificationPrompt.tsx | 68 +++++++---- src/components/shell/StartMenu.tsx | 31 +++-- 4 files changed, 162 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index a1ce6db..8a5c8bb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Runs as a web app or as a browser extension, and keeps every bit of data in your ## What you get - **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press ↑/↓ in the goal field). -- **Notifications** when a focus session or break runs out. The app offers to turn them on the first time you open it; you can also turn them on, test them or see why they are blocked from **Menu → Settings → Notifications**. +- **Notifications** when a focus session or break runs out. The app asks the first time you open it and sends a sample when you turn them on; switch them on or off any time from **Menu → Settings → Notifications**. If the sample never appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus. - **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option. - **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do. - **Habits** with 7- and 30-day grids and notes per day. diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index 41ca231..d143a2d 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -1,39 +1,80 @@ -const ICON = "/icons/icon-192.png"; -// Reusing one tag replaces a stale notification instead of stacking them. -const TAG = "boost-focus-timer"; -const OFFER_DISMISSED_KEY = "bft:notificationOfferDismissed"; +/* + * Notifications have an app-level on/off choice on top of the browser permission: + * inside the extension the permission is granted by the manifest, so without our own + * choice the user would never be asked. + */ -const isSupported = (): boolean => typeof window !== "undefined" && "Notification" in window; +const ICON_PATH = "icons/icon-128.png"; +// Reusing one id/tag replaces a stale notification instead of stacking them. +const NOTIFICATION_ID = "boost-focus-timer"; +const PREFERENCE_KEY = "bft:notifications"; +const LEGACY_DISMISSED_KEY = "bft:notificationOfferDismissed"; +export const NOTIFICATIONS_ENABLED_EVENT = "bft:notifications-enabled"; -/** True until the user has decided: the browser hasn't asked yet and they haven't said "Not now". */ -export function shouldOfferNotifications(): boolean { - return isSupported() && Notification.permission === "default" && !localStorage.getItem(OFFER_DISMISSED_KEY); +/** The slice of the extension `chrome` API used here; the web build never has it. */ +interface ExtensionApi { + runtime: { id?: string; getURL(path: string): string }; + notifications: { + create(id: string, options: { type: "basic"; iconUrl: string; title: string; message: string; silent?: boolean }): Promise; + onClicked: { addListener(listener: (id: string) => void): void }; + }; + tabs: { + getCurrent(): Promise<{ id?: number; windowId?: number } | undefined>; + update(tabId: number, properties: { active: boolean }): Promise; + }; + windows: { update(windowId: number, properties: { focused: boolean }): Promise }; } -export function dismissNotificationOffer(): void { - localStorage.setItem(OFFER_DISMISSED_KEY, "1"); +function extensionApi(): ExtensionApi | null { + const candidate = (globalThis as { chrome?: Partial }).chrome; + return candidate?.runtime?.id && candidate.notifications ? (candidate as ExtensionApi) : null; } -/** Must run from a click or key press; browsers ignore permission requests made outside a user gesture. */ -export async function requestNotificationPermission(): Promise { - if (!isSupported()) return null; - try { - return await Notification.requestPermission(); - } catch (error) { - console.error("Notification permission request failed:", error); - return null; - } +const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Notification" in window; + +export type NotificationStatus = "unsupported" | "undecided" | "on" | "off" | "blocked"; + +function readPreference(): "on" | "off" | null { + const stored = localStorage.getItem(PREFERENCE_KEY); + if (stored === "on" || stored === "off") return stored; + return localStorage.getItem(LEGACY_DISMISSED_KEY) ? "off" : null; } -export type NotificationStatus = "unsupported" | NotificationPermission; +const writePreference = (value: "on" | "off") => localStorage.setItem(PREFERENCE_KEY, value); -export const notificationStatus = (): NotificationStatus => (isSupported() ? Notification.permission : "unsupported"); +export function notificationStatus(): NotificationStatus { + const preference = readPreference(); + if (extensionApi()) return preference ?? "undecided"; + if (!hasWebNotifications()) return "unsupported"; + if (Notification.permission === "denied") return "blocked"; + if (Notification.permission === "default") return preference === "off" ? "off" : "undecided"; + return preference ?? "undecided"; +} -/** Shows a system notification when the user has allowed them; silent because the end bell already plays. */ -export function notify(title: string, body: string): void { - if (!isSupported() || Notification.permission !== "granted") return; +let clickHandlerInstalled = false; + +/** Clicking an extension notification brings the timer tab and its window to the front. */ +function installExtensionClickHandler(api: ExtensionApi): void { + if (clickHandlerInstalled) return; + clickHandlerInstalled = true; + api.notifications.onClicked.addListener(async () => { + const tab = await api.tabs.getCurrent(); + if (tab?.id !== undefined) await api.tabs.update(tab.id, { active: true }); + if (tab?.windowId !== undefined) await api.windows.update(tab.windowId, { focused: true }); + }); +} + +function show(title: string, body: string): void { + const api = extensionApi(); try { - const notification = new Notification(title, { body, icon: ICON, tag: TAG, silent: true }); + if (api) { + installExtensionClickHandler(api); + const options = { type: "basic" as const, iconUrl: api.runtime.getURL(ICON_PATH), title, message: body, silent: true }; + api.notifications.create(NOTIFICATION_ID, options).catch((error: unknown) => console.error("Could not show notification:", error)); + return; + } + // Silent because the end bell already plays; the notification is the visual cue. + const notification = new Notification(title, { body, icon: `/${ICON_PATH}`, tag: NOTIFICATION_ID, silent: true }); notification.onclick = () => { window.focus(); notification.close(); @@ -42,3 +83,33 @@ export function notify(title: string, body: string): void { console.error("Could not show notification:", error); } } + +/** Shows a notification when the user turned them on. */ +export function notify(title: string, body: string): void { + if (notificationStatus() === "on") show(title, body); +} + +/** + * Turns notifications on and sends a sample so the user sees the result straight away. + * Must run from a click: browsers ignore permission requests made outside a user gesture. + */ +export async function enableNotifications(): Promise { + if (!extensionApi() && hasWebNotifications() && Notification.permission === "default") { + const permission = await Notification.requestPermission().catch((error: unknown) => { + console.error("Notification permission request failed:", error); + return "default" as const; + }); + // Closing the browser dialog without a choice leaves the user undecided. + if (permission !== "granted") return notificationStatus(); + } + const status = notificationStatus(); + if (status === "blocked" || status === "unsupported") return status; + writePreference("on"); + show("Notifications are on", "You'll get one when a focus session or break ends."); + window.dispatchEvent(new Event(NOTIFICATIONS_ENABLED_EVENT)); + return "on"; +} + +export function disableNotifications(): void { + writePreference("off"); +} diff --git a/src/components/shell/NotificationPrompt.tsx b/src/components/shell/NotificationPrompt.tsx index df058c3..f5e185a 100644 --- a/src/components/shell/NotificationPrompt.tsx +++ b/src/components/shell/NotificationPrompt.tsx @@ -1,46 +1,66 @@ "use client"; -import { useState } from "react"; -import { dismissNotificationOffer, requestNotificationPermission, shouldOfferNotifications } from "../focus/notifications"; -import { Bell, X } from "../icons"; +import { useEffect, useState } from "react"; +import { disableNotifications, enableNotifications, notificationStatus, NOTIFICATIONS_ENABLED_EVENT } from "../focus/notifications"; +import { Bell, Check, X } from "../icons"; -/** First-visit banner: the browser only shows its permission dialog after a click, so this provides the click. */ +type Stage = "hidden" | "ask" | "sent"; + +const PANEL = + "fixed top-14 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-1.5rem)] max-w-md flex items-start gap-3 px-4 py-3 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl motion-safe:animate-[bft-sheet-in_200ms_ease-out]"; +const CLOSE_BUTTON = "shrink-0 p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"; + +/** + * Asks on first open (browsers only show their permission dialog after a click) and, + * once notifications are turned on, confirms that a sample was sent and what to check if it never arrived. + */ export function NotificationPrompt() { - const [isVisible, setIsVisible] = useState(shouldOfferNotifications); + const [stage, setStage] = useState(() => (notificationStatus() === "undecided" ? "ask" : "hidden")); + + useEffect(() => { + const showConfirmation = () => setStage("sent"); + window.addEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation); + return () => window.removeEventListener(NOTIFICATIONS_ENABLED_EVENT, showConfirmation); + }, []); - if (!isVisible) return null; + if (stage === "hidden") return null; + + if (stage === "sent") { + return ( +
+ +

+ Notifications are on, and a sample was just sent. Didn't see it? Allow notifications for your browser in your + system settings and turn off Do Not Disturb or Focus mode. +

+ +
+ ); + } const allow = async () => { - const permission = await requestNotificationPermission(); - // Closing the browser dialog leaves the permission at "default"; keep offering in that case. - if (permission !== "default") setIsVisible(false); + const status = await enableNotifications(); + if (status !== "on" && status !== "undecided") setStage("hidden"); }; const notNow = () => { - dismissNotificationOffer(); - setIsVisible(false); + disableNotifications(); + setStage("hidden"); }; return ( -
+
-

Get a notification when a focus session or break ends.

+

Get a notification when a focus session or break ends?

-
diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index 20ecbaf..b22b475 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState, type ComponentType } from "react"; -import { notificationStatus, notify, requestNotificationPermission, type NotificationStatus } from "../focus/notifications"; +import { disableNotifications, enableNotifications, notificationStatus, type NotificationStatus } from "../focus/notifications"; import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; @@ -17,27 +17,38 @@ const SETTINGS: readonly { label: string; Icon: ComponentType<{ className?: stri ]; const NOTIFICATION_LABELS: Readonly, string>> = { - default: "Turn on notifications", - granted: "Notifications on · test", - denied: "Notifications blocked", + on: "Notifications: On", + off: "Notifications: Off", + undecided: "Notifications: Off", + blocked: "Notifications: Blocked", +}; + +const NOTIFICATION_HINTS: Readonly, string>> = { + on: "Click to turn notifications off", + off: "Click to turn notifications on", + undecided: "Click to turn notifications on", + blocked: "Click to see how to allow notifications", }; const BLOCKED_HELP = "Notifications are blocked for this site. Allow them in your browser's site settings (the icon left of the address bar), then reload."; -/** One menu row that shows the current permission and does the useful next step for it. */ +/** A switch row: shows the current state and flips it; a blocked browser gets instructions instead. */ function NotificationsItem() { const [status, setStatus] = useState(notificationStatus); if (status === "unsupported") return null; - const act = async () => { - if (status === "default") setStatus((await requestNotificationPermission()) ?? notificationStatus()); - else if (status === "granted") notify("Notifications are on", "You'll get one when a focus session or break ends."); - else alert(BLOCKED_HELP); + const toggle = async () => { + if (status === "blocked") return alert(BLOCKED_HELP); + if (status === "on") { + disableNotifications(); + return setStatus("off"); + } + setStatus(await enableNotifications()); }; return ( -