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 (
- void act()} className={SUB_ITEM}>
+ void toggle()} title={NOTIFICATION_HINTS[status]} className={SUB_ITEM}>
{NOTIFICATION_LABELS[status]}