Skip to content

feat: add native notifications for promise toasts #1216

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/core/toast.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
81 changes: 77 additions & 4 deletions src/core/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -92,20 +103,37 @@ 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.
if (input == null) {
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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './constant';
export * from './cssTransition';
export * from './collapseToast';
export * from './mapper';
export * from './userActionInfo';
39 changes: 39 additions & 0 deletions src/utils/userActionInfo.ts
Original file line number Diff line number Diff line change
@@ -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));
}