diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f74739e7..84f21a2a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,12 +26,12 @@ jobs: run: pnpm build - name: Test run: pnpm run test:run - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: cypress/screenshots - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: cypress-videos diff --git a/src/core/toast.cy.tsx b/src/core/toast.cy.tsx index f522d0a0..e833f07b 100644 --- a/src/core/toast.cy.tsx +++ b/src/core/toast.cy.tsx @@ -689,3 +689,60 @@ describe('with stacked container', () => { cy.findByText('hello 3').should('exist').and('be.visible'); }); }); + +describe('browser notifications', () => { + beforeEach(() => { + cy.viewport('macbook-15'); + + // Stub the Notification constructor + cy.stub(window, 'Notification').as('Notification').returns({ + close: cy.stub() // Stub the close method of the notification + }); + + // Stub requestPermission to always resolve with 'granted' + cy.stub(window.Notification, 'requestPermission').resolves('granted'); + }); + + it('should display a success browser notification when promise resolves and user is inactive', () => { + const successTitle = 'Success Title'; + const successBody = 'Operation completed successfully!'; + + // Simulate user being inactive in the tab + cy.stub(document, 'visibilityState').value('hidden'); + + toast.promise(Promise.resolve('Resolved Data'), { + pending: 'Loading...', + success: 'Promise resolved!', + browserNotification: { + success: { + title: successTitle, + options: { body: successBody } + }, + jusIfUserNotInActiveTab: true // Send notification only if user is inactive + } + }); + + // Assert that the Notification constructor was called with the correct arguments + cy.get('@Notification').should('have.been.calledWith', successTitle, { body: successBody }); + }); + + it('should not display a browser notification if user is active in the tab and jusIfUserNotInActiveTab is true', () => { + // Simulate user being active in the tab + cy.stub(document, 'visibilityState').value('visible'); + + toast.promise(Promise.resolve('Resolved Data'), { + pending: 'Loading...', + success: 'Promise resolved!', + browserNotification: { + success: { + title: 'Success Title', + options: { body: 'Success Body' } + }, + jusIfUserNotInActiveTab: true // Send notification only if user is inactive + } + }); + + // Assert that the Notification constructor was NOT called + cy.get('@Notification').should('not.have.been.called'); + }); +}); diff --git a/src/core/toast.ts b/src/core/toast.ts index b859b164..a41b86a7 100644 --- a/src/core/toast.ts +++ b/src/core/toast.ts @@ -10,7 +10,7 @@ import { TypeOptions, UpdateOptions } from '../types'; -import { isFn, isNum, isStr, Type } from '../utils'; +import { createBrowserNotification, isFn, isNum, isStr, isUserActiveInTab, Type } from '../utils'; import { genToastId } from './genToastId'; import { clearWaitingQueue, getToast, isToastActive, onChange, pushToast, removeToast, toggleToast } from './store'; @@ -66,11 +66,22 @@ export interface ToastPromiseParams<TData = unknown, TError = unknown, TPending pending?: string | UpdateOptions<TPending>; success?: string | UpdateOptions<TData>; error?: string | UpdateOptions<TError>; + browserNotification?: { + success?: { + title: string; + options: NotificationOptions; + }; + error?: { + title: string; + options: NotificationOptions; + }; + jusIfUserNotInActiveTab?: boolean; + }; } function handlePromise<TData = unknown, TError = unknown, TPending = unknown>( promise: Promise<TData> | (() => Promise<TData>), - { pending, error, success }: ToastPromiseParams<TData, TError, TPending>, + { pending, error, success, browserNotification }: ToastPromiseParams<TData, TError, TPending>, options?: ToastOptions<TData> ) { let id: Id; @@ -92,6 +103,23 @@ function handlePromise<TData = unknown, TError = unknown, TPending = unknown>( draggable: null }; + const fireBrowserNotification = (type: TypeOptions) => { + if (!browserNotification) return; + + const notificationConfig = type === 'success' ? browserNotification.success : browserNotification.error; + + // Check if the notification should be triggered based on the following conditions: + // 1. `notificationConfig` must exist (i.e., a valid configuration for the browser notification is provided). + // 2. The notification should be sent either: + // a) If `jusIfUserNotInActiveTab` is false (meaning the notification should always be sent regardless of the user's tab activity), OR + // b) If `jusIfUserNotInActiveTab` is true AND the user is NOT active in the current tab (i.e., the user has switched to another tab or minimized the window). + // If any of these conditions are met, proceed to create and display the browser notification. + + if (notificationConfig && (!browserNotification.jusIfUserNotInActiveTab || !isUserActiveInTab())) { + createBrowserNotification(notificationConfig.title, notificationConfig.options); + } + }; + const resolver = <T>(type: TypeOptions, input: string | UpdateOptions<T> | undefined, result: T) => { // Remove the toast if the input has not been provided. This prevents the toast from hanging // in the pending state if a success/error toast has not been provided. @@ -99,13 +127,13 @@ function handlePromise<TData = unknown, TError = unknown, TPending = unknown>( toast.dismiss(id); return; } - const baseParams = { type, ...resetParams, ...options, data: result }; + const params = isStr(input) ? { render: input } : input; // if the id is set we know that it's an update @@ -122,13 +150,20 @@ function handlePromise<TData = unknown, TError = unknown, TPending = unknown>( } as ToastOptions<T>); } + // send notification to browser after in resolve step of promise toast + fireBrowserNotification(type); + return result; }; const p = isFn(promise) ? promise() : promise; //call the resolvers only when needed - p.then(result => resolver('success', success, result)).catch(err => resolver('error', error, err)); + p.then(result => { + resolver('success', success, result); + }).catch(err => { + resolver('error', error, err); + }); return p; } @@ -173,6 +208,44 @@ function handlePromise<TData = unknown, TError = unknown, TPending = unknown>( * } * ) * ``` + * Notify the distracted user by Browser Notification after the promise is resolved: + * ``` + * toast.promise<{name: string}, {message: string}, undefined>( + * resolveWithSomeData, + * { + * pending: { + * render: () => "I'm loading", + * icon: false, + * }, + * success: { + * render: ({data}) => `Hello ${data.name}`, + * icon: "🟢", + * }, + * error: { + * render({data}){ + * // When the promise reject, data will contains the error + * return <MyErrorComponent message={data.message} /> + * } + * }, + * browserNotification:{ + * jusIfUserNotInActiveTab:true, + * success:{ + * title:"Come back , Its Done", + * options:{ + * body:"It's Done body , where are you looking ? ", + * } + * }, + * error:{ + * title:"We have a problem !", + * options:{ + * body:"It's look like we failed to connect ", + * } + * } + * } + * + * } + * ) + * ``` */ toast.promise = handlePromise; toast.success = createToastByType(Type.SUCCESS); diff --git a/src/utils/index.ts b/src/utils/index.ts index 5aaf837e..2eb2ef3a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './constant'; export * from './cssTransition'; export * from './collapseToast'; export * from './mapper'; +export * from './userActionInfo'; diff --git a/src/utils/userActionInfo.ts b/src/utils/userActionInfo.ts new file mode 100644 index 00000000..b687c979 --- /dev/null +++ b/src/utils/userActionInfo.ts @@ -0,0 +1,39 @@ +export function isUserActiveInTab(): boolean { + if (typeof document !== 'undefined') + // document.visibilityState returns 'visible' if the page is visible + return document.visibilityState === 'visible'; +} + +/** + * Creates a browser notification. + * + * @param {string} title - The title of the notification. + * @param {object} options - Options for the notification. + * @param {string} options.body - The body text of the notification. + * @param {string} [options.icon] - The URL of the icon to display in the notification. + * @returns {Promise<Notification>} - A promise that resolves with the created notification object. + */ + +export function createBrowserNotification(title: string, options: NotificationOptions): Promise<Notification> { + // Check if the browser supports notifications + if (!('Notification' in window)) { + console.error('This browser does not support desktop notifications.'); + return Promise.reject(new Error('Notifications are not supported in this browser.')); + } + + // Request permission if not already granted + if (Notification.permission !== 'granted') { + return Notification.requestPermission().then(permission => { + if (permission !== 'granted') { + console.error('User denied notification permission.'); + return Promise.reject(new Error('Notification permission denied by user.')); + } + + // Create and return the notification + return new Notification(title, options); + }); + } + + // If permission is already granted, create the notification + return Promise.resolve(new Notification(title, options)); +}