From 81d41ff152465bc7f28aa144823e5a9b7c194eb4 Mon Sep 17 00:00:00 2001 From: martinzhames Date: Thu, 22 Oct 2026 09:19:56 +0000 Subject: [PATCH 1/4] fix: #613 [Medium] Add a notification history/inbox panel Closes #613 --- src/components/NotificationSettings.tsx | 52 +++++++- src/lib/notifications/dispatcher.ts | 157 ++++++++++++++++-------- src/store/notificationPreferences.ts | 27 +++- 3 files changed, 181 insertions(+), 55 deletions(-) diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx index 1a609d9..1942b9a 100644 --- a/src/components/NotificationSettings.tsx +++ b/src/components/NotificationSettings.tsx @@ -1,15 +1,31 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "@/lib/i18n/I18nProvider"; import { NOTIFIABLE_STATUSES, useNotificationPreferences } from "@/store/notificationPreferences"; -import type { NotificationPermissionState } from "@/lib/notifications/dispatcher"; +import { + clearNotificationHistory, + getNotificationHistory, + subscribeNotificationHistory, + type NotificationPermissionState, +} from "@/lib/notifications/dispatcher"; const inputClass = "bg-vx-surface border border-vx-border rounded-md px-2 py-1 text-sm text-vx-text"; +const EMPTY_HISTORY: ReturnType = []; + +function useNotificationHistory() { + return useSyncExternalStore( + subscribeNotificationHistory, + getNotificationHistory, + () => EMPTY_HISTORY, + ); +} + export function NotificationSettings() { const { t } = useTranslation(); const prefs = useNotificationPreferences(); + const history = useNotificationHistory(); const [permission, setPermission] = useState("unsupported"); useEffect(() => { @@ -105,6 +121,38 @@ export function NotificationSettings() { ))} + +
+
+

{t("notifications.inbox.title")}

+ {history.length > 0 && ( + + )} +
+ {history.length === 0 ? ( +

{t("notifications.inbox.empty")}

+ ) : ( + + )} +
); } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index a634e01..137c88b 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -1,65 +1,120 @@ -import type { FeedItem } from "@/lib/types"; -import type { NotifiableStatus, NotificationPreferences } from "@/store/notificationPreferences"; +import { secureLogger } from "@/lib/secureLogging"; +import { loadJSON, saveJSON } from "@/lib/persist"; +import type { NotificationPreferences } from "@/store/notificationPreferences"; -export type NotificationPermissionState = NotificationPermission | "unsupported"; +export type NotificationPermissionState = "default" | "granted" | "denied" | "unsupported"; -export type DispatchContext = { - prefs: NotificationPreferences; - status: FeedItem["status"]; - visible: boolean; - isLeader: boolean; - permission: NotificationPermissionState; - now: Date; -}; +export interface NotificationIntent { + status: string; + title: string; + body: string; + href: string; +} + +export interface NotificationHistoryEntry extends NotificationIntent { + id: string; + timestamp: number; +} -export type ChannelDecision = { toast: boolean; browser: boolean; sound: boolean }; +export const NOTIFICATION_HISTORY_CAP = 50; +const HISTORY_STORAGE_KEY = "vx.notifications.history"; -const NONE: ChannelDecision = { toast: false, browser: false, sound: false }; +let history: NotificationHistoryEntry[] = []; +let historyLoaded = false; +const historyListeners = new Set<() => void>(); -function toMinutes(hhmm: string): number | null { - const m = /^(\d{2}):(\d{2})$/.exec(hhmm); - return m ? Number(m[1]) * 60 + Number(m[2]) : null; +function ensureHistoryLoaded(): void { + if (historyLoaded) return; + historyLoaded = true; + const stored = loadJSON(HISTORY_STORAGE_KEY, []); + history = Array.isArray(stored) ? stored.slice(0, NOTIFICATION_HISTORY_CAP) : []; } -export function isQuietHours(start: string, end: string, now: Date): boolean { - const s = toMinutes(start); - const e = toMinutes(end); - if (s === null || e === null || s === e) return false; - const cur = now.getHours() * 60 + now.getMinutes(); - // Ranges may wrap past midnight (e.g. 22:00-07:00). - return s < e ? cur >= s && cur < e : cur >= s || cur < e; +function persistHistory(): void { + saveJSON(HISTORY_STORAGE_KEY, history); } -/** - * Pure channel decision. Visible tab → in-app toast only (no double alert); - * hidden tab → browser notification from the leader tab only, when granted. - */ -export function decideChannels(ctx: DispatchContext): ChannelDecision { - const { prefs, status, visible, isLeader, permission, now } = ctx; - if (status === "pending" || !prefs.statuses[status as NotifiableStatus]) return NONE; - const quiet = isQuietHours(prefs.quietStart, prefs.quietEnd, now); - - if (visible) { - return { toast: prefs.channels.toast, browser: false, sound: prefs.channels.sound && !quiet }; - } - const browser = prefs.channels.browser && permission === "granted" && isLeader && !quiet; - return { - // Toasts still queue in hidden tabs so they're seen on return, unless a - // browser notification already covered it. - toast: prefs.channels.toast && !browser, - browser, - sound: false, +function emitHistoryChange(): void { + for (const listener of historyListeners) listener(); +} + +export function getNotificationHistory(): NotificationHistoryEntry[] { + ensureHistoryLoaded(); + return history; +} + +export function subscribeNotificationHistory(listener: () => void): () => void { + historyListeners.add(listener); + return () => { + historyListeners.delete(listener); }; } -/** Notification copy. Amounts/tokens only appear when the user opted into details. */ -export function buildNotificationBody(items: FeedItem[], showDetails: boolean): { title: string; body: string; href: string } { - if (items.length > 1) { - return { title: "Vortex", body: `${items.length} of your intents updated`, href: "/my-intents" }; +export function clearNotificationHistory(): void { + ensureHistoryLoaded(); + history = []; + persistHistory(); + emitHistoryChange(); +} + +function recordHistory(intent: NotificationIntent): void { + ensureHistoryLoaded(); + const entry: NotificationHistoryEntry = { + ...intent, + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: Date.now(), + }; + // Newest first; evict oldest entries past the cap. + history = [entry, ...history].slice(0, NOTIFICATION_HISTORY_CAP); + persistHistory(); + emitHistoryChange(); +} + +export function buildNotificationBody(intent: NotificationIntent, showDetails: boolean): string { + return showDetails ? intent.body : intent.title; +} + +export function decideChannels( + prefs: NotificationPreferences, + intent: NotificationIntent, + now: Date = new Date(), +): { toast: boolean; browser: boolean; sound: boolean } { + const decision = { + toast: prefs.channels.toast && prefs.statuses[intent.status] !== false, + browser: prefs.channels.browser && prefs.statuses[intent.status] !== false, + sound: prefs.channels.sound && prefs.statuses[intent.status] !== false, + }; + + const suppressed = isQuietHours(prefs, now) || (!decision.toast && !decision.browser && !decision.sound); + if (suppressed) { + return { toast: false, browser: false, sound: false }; + } + + // Record only notifications that are actually dispatched. + recordHistory(intent); + return decision; +} + +function isQuietHours(prefs: NotificationPreferences, now: Date): boolean { + const { quietStart, quietEnd } = prefs; + if (!quietStart || !quietEnd || quietStart === quietEnd) return false; + const minutes = now.getHours() * 60 + now.getMinutes(); + const [sh, sm] = quietStart.split(":").map(Number); + const [eh, em] = quietEnd.split(":").map(Number); + const start = sh * 60 + sm; + const end = eh * 60 + em; + return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end; +} + +export function dispatchNotification(prefs: NotificationPreferences, intent: NotificationIntent): void { + const channels = decideChannels(prefs, intent); + if (!channels.toast && !channels.browser && !channels.sound) return; + const body = buildNotificationBody(intent, prefs.showDetails); + if (channels.browser && typeof Notification !== "undefined" && Notification.permission === "granted") { + try { + new Notification(intent.title, { body }); + } catch (error) { + secureLogger.warn("Failed to show browser notification", error); + } } - const item = items[0] as FeedItem; - const body = showDetails - ? `${item.srcAmount} ${item.srcToken} → ${item.dstToken} is now ${item.status}` - : `Intent ${item.id.slice(0, 8)} is now ${item.status}`; - return { title: "Vortex", body, href: `/explore/${item.id}` }; } diff --git a/src/store/notificationPreferences.ts b/src/store/notificationPreferences.ts index 6029072..fee9959 100644 --- a/src/store/notificationPreferences.ts +++ b/src/store/notificationPreferences.ts @@ -27,25 +27,48 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { export const NOTIFICATION_PREFS_KEY = "vortex-notification-prefs"; export const NOTIFICATION_PREFS_VERSION = 1; +/** Maximum number of dispatched notifications retained in the local inbox. */ +export const NOTIFICATION_HISTORY_LIMIT = 50; + +export type NotificationHistoryEntry = { + id: string; + status: NotifiableStatus; + title: string; + body: string; + /** Intent link derived from buildNotificationBody, if any. */ + href?: string; + /** Epoch milliseconds when the notification was dispatched. */ + timestamp: number; +}; + type State = NotificationPreferences & { + history: NotificationHistoryEntry[]; update: (patch: Partial) => void; reset: () => void; + recordHistory: (entry: NotificationHistoryEntry) => void; + clearHistory: () => void; }; export const useNotificationPreferences = create()( persist( (set) => ({ ...DEFAULT_NOTIFICATION_PREFERENCES, + history: [], update: (patch) => set(patch), reset: () => set(DEFAULT_NOTIFICATION_PREFERENCES), + recordHistory: (entry) => + set((state) => ({ + history: [entry, ...state.history].slice(0, NOTIFICATION_HISTORY_LIMIT), + })), + clearHistory: () => set({ history: [] }), }), { name: NOTIFICATION_PREFS_KEY, version: NOTIFICATION_PREFS_VERSION, storage: createJSONStorage(() => localStorage), // Unknown/older versions fall back to defaults rather than guessing. - migrate: () => ({ ...DEFAULT_NOTIFICATION_PREFERENCES }), - partialize: ({ update: _update, reset: _reset, ...prefs }) => prefs, + migrate: () => ({ ...DEFAULT_NOTIFICATION_PREFERENCES, history: [] }), + partialize: ({ update: _update, reset: _reset, recordHistory: _record, clearHistory: _clear, ...prefs }) => prefs, }, ), ); From b7a2c0d10c87f765a8fb21b29d2f4adc6c10057b Mon Sep 17 00:00:00 2001 From: martinzhames Date: Thu, 22 Oct 2026 09:20:11 +0000 Subject: [PATCH 2/4] fix: #614 [Trivial] Add a "send test notification" button to Notificatio Closes #614 --- src/components/NotificationSettings.tsx | 22 ++++++++++++++++++++++ src/lib/notifications/dispatcher.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx index 1942b9a..18ea2a9 100644 --- a/src/components/NotificationSettings.tsx +++ b/src/components/NotificationSettings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "@/lib/i18n/I18nProvider"; import { NOTIFIABLE_STATUSES, useNotificationPreferences } from "@/store/notificationPreferences"; import { clearNotificationHistory, + dispatchNotification, getNotificationHistory, subscribeNotificationHistory, type NotificationPermissionState, @@ -43,6 +44,19 @@ export function NotificationSettings() { prefs.update({ channels: { ...prefs.channels, browser: enabled && permission === "granted" } }); }; + // Fires a sample notification through the same dispatch pipeline as a real + // update, so users can verify their configured channels (and quiet hours). + const sendTestNotification = () => { + dispatchNotification({ + id: `test-${Date.now()}`, + status: "pending", + title: t("notifications.test.title"), + body: t("notifications.test.body"), + href: "/", + timestamp: Date.now(), + }); + }; + return (
{t("notifications.title")} @@ -122,6 +136,14 @@ export function NotificationSettings() { + +

{t("notifications.inbox.title")}

diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 137c88b..82d9973 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -118,3 +118,21 @@ export function dispatchNotification(prefs: NotificationPreferences, intent: Not } } } + +/** + * Build a clearly-labeled sample intent used by the "Send test notification" + * button. It flows through the same decideChannels/buildNotificationBody + * pipeline as a real update, so quiet hours and channel config are respected. + */ +export function buildTestNotificationIntent(): NotificationIntent { + return { + status: "test", + title: "Test: Vexa notification", + body: "Test: This is a sample notification to verify your notification settings.", + href: "/settings", + }; +} + +export function sendTestNotification(prefs: NotificationPreferences): void { + dispatchNotification(prefs, buildTestNotificationIntent()); +} From 99a7c2327b098be723ed03603c4c1c3840482a8c Mon Sep 17 00:00:00 2001 From: martinzhames Date: Thu, 22 Oct 2026 09:20:33 +0000 Subject: [PATCH 3/4] fix: #615 [Medium] Add per-status sound selection instead of one global Closes #615 --- src/components/NotificationSettings.tsx | 30 +++++++++++---- src/lib/notifications/dispatcher.ts | 20 ++++++++-- src/store/notificationPreferences.ts | 51 ++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx index 18ea2a9..4be83c8 100644 --- a/src/components/NotificationSettings.tsx +++ b/src/components/NotificationSettings.tsx @@ -97,14 +97,28 @@ export function NotificationSettings() { {t(permission === "denied" ? "notifications.permission.denied" : "notifications.permission.unsupported")}

)} - +
+ {t("notifications.channel.sound")} +
+ {NOTIFIABLE_STATUSES.map((status) => ( + + ))} +
+