38
common/resources/client/notifications/database-notification.ts
Executable file
38
common/resources/client/notifications/database-notification.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import {ButtonProps} from '../ui/buttons/button';
|
||||
|
||||
export interface DatabaseNotification {
|
||||
id: string;
|
||||
read_at: string;
|
||||
created_at: string;
|
||||
type: string;
|
||||
data: DatabaseNotificationData;
|
||||
}
|
||||
|
||||
export interface DatabaseNotificationAction {
|
||||
label: string;
|
||||
action: string;
|
||||
// only emit "notificationClicked" event on notification
|
||||
// server and don't open "action" link in new window
|
||||
emitOnly?: boolean;
|
||||
color?: ButtonProps['color'];
|
||||
}
|
||||
|
||||
export interface DatabaseNotificationData {
|
||||
image: string;
|
||||
warning?: boolean;
|
||||
mainAction?: DatabaseNotificationAction;
|
||||
buttonActions?: DatabaseNotificationAction[];
|
||||
lines: DatabaseNotificationLine[];
|
||||
}
|
||||
|
||||
export interface DatabaseNotificationLine {
|
||||
content: string;
|
||||
icon?: string;
|
||||
type?: 'secondary' | 'primary';
|
||||
action?: DatabaseNotificationAction;
|
||||
}
|
||||
|
||||
export interface BroadcastNotification extends DatabaseNotificationData {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
119
common/resources/client/notifications/dialog/notification-dialog-trigger.tsx
Executable file
119
common/resources/client/notifications/dialog/notification-dialog-trigger.tsx
Executable file
@@ -0,0 +1,119 @@
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {NotificationsIcon} from '../../icons/material/Notifications';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {useUserNotifications} from './requests/user-notifications';
|
||||
import {ProgressCircle} from '../../ui/progress/progress-circle';
|
||||
import {NotificationList} from '../notification-list';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {Dialog} from '../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useAuth} from '../../auth/use-auth';
|
||||
import {Badge} from '../../ui/badge/badge';
|
||||
import {DoneAllIcon} from '../../icons/material/DoneAll';
|
||||
import {useMarkNotificationsAsRead} from '../requests/use-mark-notifications-as-read';
|
||||
import {NotificationEmptyStateMessage} from '../empty-state/notification-empty-state-message';
|
||||
import {SettingsIcon} from '@common/icons/material/Settings';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
interface NotificationDialogTriggerProps {
|
||||
className?: string;
|
||||
}
|
||||
export function NotificationDialogTrigger({
|
||||
className,
|
||||
}: NotificationDialogTriggerProps) {
|
||||
const {user} = useAuth();
|
||||
const {notif} = useSettings();
|
||||
const query = useUserNotifications();
|
||||
const markAsRead = useMarkNotificationsAsRead();
|
||||
const hasUnread = !!user?.unread_notifications_count;
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!query.data) return;
|
||||
markAsRead.mutate({
|
||||
markAllAsUnread: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger type="popover">
|
||||
<IconButton
|
||||
size="md"
|
||||
className={className}
|
||||
badge={
|
||||
hasUnread ? (
|
||||
<Badge className="max-md:hidden">
|
||||
{user?.unread_notifications_count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<NotificationsIcon />
|
||||
</IconButton>
|
||||
<Dialog>
|
||||
<DialogHeader
|
||||
showDivider
|
||||
actions={
|
||||
!hasUnread &&
|
||||
notif.subs.integrated && (
|
||||
<IconButton
|
||||
className="text-muted"
|
||||
size="sm"
|
||||
elementType={Link}
|
||||
to="/notifications/settings"
|
||||
target="_blank"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
rightAdornment={
|
||||
hasUnread && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="xs"
|
||||
startIcon={<DoneAllIcon />}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={markAsRead.isPending}
|
||||
className="max-md:hidden"
|
||||
>
|
||||
<Trans message="Mark all as read" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans message="Notifications" />
|
||||
</DialogHeader>
|
||||
<DialogBody padding="p-0">
|
||||
<DialogContent />
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent() {
|
||||
const {data, isLoading} = useUserNotifications();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-24 py-20">
|
||||
<ProgressCircle aria-label="Loading notifications..." isIndeterminate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data?.pagination.data.length) {
|
||||
return (
|
||||
<div className="px-24 py-20">
|
||||
<NotificationEmptyStateMessage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<NotificationList notifications={data.pagination.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
common/resources/client/notifications/dialog/requests/user-notifications.ts
Executable file
32
common/resources/client/notifications/dialog/requests/user-notifications.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {PaginatedBackendResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {DatabaseNotification} from '@common/notifications/database-notification';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
const Endpoint = 'notifications';
|
||||
|
||||
export interface FetchUserNotificationsResponse
|
||||
extends PaginatedBackendResponse<DatabaseNotification> {
|
||||
//
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export function useUserNotifications(payload?: Payload) {
|
||||
return useQuery({
|
||||
queryKey: useUserNotifications.key,
|
||||
queryFn: () => fetchUserNotifications(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchUserNotifications(
|
||||
payload?: Payload,
|
||||
): Promise<FetchUserNotificationsResponse> {
|
||||
return apiClient
|
||||
.get(Endpoint, {params: payload})
|
||||
.then(response => response.data);
|
||||
}
|
||||
|
||||
useUserNotifications.key = [Endpoint];
|
||||
@@ -0,0 +1,34 @@
|
||||
import {IllustratedMessage} from '../../ui/images/illustrated-message';
|
||||
import {SvgImage} from '../../ui/images/svg-image/svg-image';
|
||||
import notifySvg from './notify.svg';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
|
||||
export function NotificationEmptyStateMessage() {
|
||||
const {notif} = useSettings();
|
||||
return (
|
||||
<IllustratedMessage
|
||||
size="sm"
|
||||
image={<SvgImage src={notifySvg} />}
|
||||
title={<Trans message="Hang tight!" />}
|
||||
description={
|
||||
<Trans message="Notifications will start showing up here soon." />
|
||||
}
|
||||
action={
|
||||
notif.subs.integrated && (
|
||||
<Button
|
||||
elementType={Link}
|
||||
variant="outline"
|
||||
to="/notifications/settings"
|
||||
size="xs"
|
||||
color="primary"
|
||||
>
|
||||
<Trans message="Notification settings" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/notifications/empty-state/notify.svg
Executable file
1
common/resources/client/notifications/empty-state/notify.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="790" height="512.20805" viewBox="0 0 790 512.20805" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M925.56335,704.58909,903,636.49819s24.81818,24.81818,24.81818,45.18181l-4.45454-47.09091s12.72727,17.18182,11.45454,43.27273S925.56335,704.58909,925.56335,704.58909Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><path d="M441.02093,642.58909,419,576.13509s24.22155,24.22155,24.22155,44.09565l-4.34745-45.95885s12.42131,16.76877,11.17917,42.23245S441.02093,642.58909,441.02093,642.58909Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><path d="M784.72555,673.25478c.03773,43.71478-86.66489,30.26818-192.8092,30.35979s-191.53562,13.68671-191.57335-30.028,86.63317-53.29714,192.77748-53.38876S784.68782,629.54,784.72555,673.25478Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><rect y="509.69312" width="790" height="2" fill="#3f3d56"/><polygon points="505.336 420.322 491.459 420.322 484.855 366.797 505.336 366.797 505.336 420.322" fill="#a0616a"/><path d="M480.00587,416.35743H508.3101a0,0,0,0,1,0,0V433.208a0,0,0,0,1,0,0H464.69674a0,0,0,0,1,0,0v-1.54149A15.30912,15.30912,0,0,1,480.00587,416.35743Z" fill="#2f2e41"/><polygon points="607.336 499.322 593.459 499.322 586.855 445.797 607.336 445.797 607.336 499.322" fill="#a0616a"/><path d="M582.00587,495.35743H610.3101a0,0,0,0,1,0,0V512.208a0,0,0,0,1,0,0H566.69674a0,0,0,0,1,0,0v-1.54149A15.30912,15.30912,0,0,1,582.00587,495.35743Z" fill="#2f2e41"/><path d="M876.34486,534.205A10.31591,10.31591,0,0,0,873.449,518.654l-32.23009-131.2928L820.6113,396.2276l38.33533,126.949a10.37185,10.37185,0,0,0,17.39823,11.0284Z" transform="translate(-205 -193.89598)" fill="#a0616a"/><path d="M851.20767,268.85955a11.38227,11.38227,0,0,0-17.41522,1.15247l-49.88538,5.72709,7.58861,19.24141,45.36779-8.49083a11.44393,11.44393,0,0,0,14.3442-17.63014Z" transform="translate(-205 -193.89598)" fill="#a0616a"/><path d="M769,520.58909l21.76811,163.37417,27.09338-5.578s-3.98437-118.98157,9.56238-133.32513S810,505.58909,810,505.58909Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><path d="M778,475.58909l-10,15s-77-31.99929-77,19-4.40631,85.60944-6,88,18.43762,8.59375,28,7c0,0,11.79687-82.21884,11-87,0,0,75.53355,37.03335,89.87712,33.84591S831.60944,536.964,834,530.58909s-1-57-1-57l-47.81-14.59036Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><path d="M779.34915,385.52862l-2.85032-3.42039s-31.92361-71.82815-19.3822-91.21035,67.26762-22.23252,68.97783-21.0924-4.08488,15.9428-.09446,22.78361c0,0-42.394,9.19121-45.24435,10.33134s21.96615,43.2737,21.96615,43.2737l-2.85031,25.6529Z" transform="translate(-205 -193.89598)" fill="#ccc"/><path d="M835.21549,350.18459S805.57217,353.605,804.432,353.605s-1.71017-7.41084-1.71017-7.41084l-26.223,35.91406S763.57961,486.29929,767,484.58909s66.50531,8.11165,67.07539,3.55114-.57008-27.3631,1.14014-28.50324,29.64328-71.82811,29.64328-71.82811-2.85032-14.82168-12.54142-19.95227S835.21549,350.18459,835.21549,350.18459Z" transform="translate(-205 -193.89598)" fill="#ccc"/><path d="M855.73783,378.11779l9.121,9.69109S878.41081,499.1687,871,502.58909s-22,3-22,3l-14.35458-52.79286Z" transform="translate(-205 -193.89598)" fill="#ccc"/><circle cx="601.72966" cy="122.9976" r="26.2388" fill="#a0616a"/><path d="M800.57267,320.98789c-.35442-5.44445-7.22306-5.631-12.67878-5.68255s-11.97836.14321-15.0654-4.35543c-2.0401-2.973-1.65042-7.10032.035-10.28779s4.45772-5.639,7.18508-7.99742c7.04139-6.08884,14.29842-12.12936,22.7522-16.02662s18.36045-5.472,27.12788-2.3435c10.77008,3.84307,25.32927,23.62588,26.5865,34.99176s-3.28507,22.95252-10.9419,31.44586-25.18188,5.0665-36.21069,8.088c6.7049-9.48964,2.28541-26.73258-8.45572-31.164Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><circle cx="361.7217" cy="403.5046" r="62.98931" fill="rgb(var(--be-primary))"/><path d="M524.65625,529.9355a45.15919,45.15919,0,0,1-41.25537-26.78614L383.44873,278.05757a59.83039,59.83039,0,1,1,111.87012-41.86426l72.37744,235.41211a45.07978,45.07978,0,0,1-43.04,58.33008Z" transform="translate(-205 -193.89598)" fill="rgb(var(--be-primary))"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
56
common/resources/client/notifications/notification-line.tsx
Executable file
56
common/resources/client/notifications/notification-line.tsx
Executable file
@@ -0,0 +1,56 @@
|
||||
import {MixedImage} from '../ui/images/mixed-image';
|
||||
import clsx from 'clsx';
|
||||
import React, {JSXElementConstructor} from 'react';
|
||||
import {
|
||||
DatabaseNotification,
|
||||
DatabaseNotificationLine,
|
||||
} from './database-notification';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
|
||||
interface LineProps {
|
||||
notification: DatabaseNotification;
|
||||
line: DatabaseNotificationLine;
|
||||
index: number;
|
||||
iconRenderer?: JSXElementConstructor<{icon: string}>;
|
||||
}
|
||||
|
||||
export function Line({notification, line, index, iconRenderer}: LineProps) {
|
||||
const isPrimary = line.type === 'primary' || index === 0;
|
||||
const Icon = iconRenderer || DefaultIconRenderer;
|
||||
const Element = line.action ? 'a' : 'div';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Element
|
||||
key={index}
|
||||
className={clsx(
|
||||
'flex items-center gap-8',
|
||||
line.action && 'hover:underline',
|
||||
isPrimary
|
||||
? 'text-sm mnarktext-main whitespace-nowrap'
|
||||
: 'text-xs text-muted mt-6'
|
||||
)}
|
||||
href={line.action?.action}
|
||||
title={line.action?.label}
|
||||
>
|
||||
{line.icon && <Icon icon={line.icon} />}
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis"
|
||||
dangerouslySetInnerHTML={{__html: line.content}}
|
||||
/>
|
||||
</Element>
|
||||
{index === 0 && (
|
||||
<time className="text-xs text-muted">
|
||||
<FormattedRelativeTime date={notification.created_at} />
|
||||
</time>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultIconRendererProps {
|
||||
icon: string;
|
||||
}
|
||||
function DefaultIconRenderer({icon}: DefaultIconRendererProps) {
|
||||
return <MixedImage src={icon} />;
|
||||
}
|
||||
156
common/resources/client/notifications/notification-list.tsx
Executable file
156
common/resources/client/notifications/notification-list.tsx
Executable file
@@ -0,0 +1,156 @@
|
||||
import React, {JSXElementConstructor, useContext} from 'react';
|
||||
import {GroupAddIcon} from '../icons/material/GroupAdd';
|
||||
import {PeopleIcon} from '../icons/material/People';
|
||||
import {FileDownloadDoneIcon} from '../icons/material/FileDownloadDone';
|
||||
import {
|
||||
DatabaseNotification,
|
||||
DatabaseNotificationAction,
|
||||
} from './database-notification';
|
||||
import {MixedImage} from '../ui/images/mixed-image';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import {SiteConfigContext} from '../core/settings/site-config-context';
|
||||
import {Line} from './notification-line';
|
||||
import {SvgIconProps} from '../icons/svg-icon';
|
||||
import clsx from 'clsx';
|
||||
import {useMarkNotificationsAsRead} from './requests/use-mark-notifications-as-read';
|
||||
import {useNavigate} from '../utils/hooks/use-navigate';
|
||||
import {isAbsoluteUrl} from '../utils/urls/is-absolute-url';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useSettings} from '../core/settings/use-settings';
|
||||
|
||||
const iconMap = {
|
||||
'group-add': GroupAddIcon,
|
||||
people: PeopleIcon,
|
||||
'export-csv': FileDownloadDoneIcon,
|
||||
} as Record<string, JSXElementConstructor<SvgIconProps>>;
|
||||
|
||||
interface NotificationListProps {
|
||||
notifications: DatabaseNotification[];
|
||||
className?: string;
|
||||
}
|
||||
export function NotificationList({
|
||||
notifications,
|
||||
className,
|
||||
}: NotificationListProps) {
|
||||
const {notifications: config} = useContext(SiteConfigContext);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{notifications.map((notification, index) => {
|
||||
const isLast = notifications.length - 1 === index;
|
||||
const Renderer =
|
||||
config?.renderMap?.[notification.type] || NotificationListItem;
|
||||
return (
|
||||
<Renderer
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NotificationListItemProps {
|
||||
notification: DatabaseNotification;
|
||||
onActionButtonClick?: ButtonActionsProps['onActionClick'];
|
||||
lineIconRenderer?: JSXElementConstructor<{icon: string}>;
|
||||
isLast: boolean;
|
||||
}
|
||||
export function NotificationListItem({
|
||||
notification,
|
||||
onActionButtonClick,
|
||||
lineIconRenderer,
|
||||
isLast,
|
||||
}: NotificationListItemProps) {
|
||||
const markAsRead = useMarkNotificationsAsRead();
|
||||
const navigate = useNavigate();
|
||||
const mainAction = notification.data.mainAction;
|
||||
|
||||
const showUnreadIndicator = !notification.data.image && !notification.read_at;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!markAsRead.isPending && !notification.read_at) {
|
||||
markAsRead.mutate({ids: [notification.id]});
|
||||
}
|
||||
if (mainAction?.action) {
|
||||
if (isAbsoluteUrl(mainAction.action)) {
|
||||
window.open(mainAction.action, '_blank')?.focus();
|
||||
} else {
|
||||
navigate(mainAction.action);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
'flex items-start gap-14 px-32 py-20 bg-alt relative',
|
||||
!isLast && 'border-b',
|
||||
mainAction?.action && 'cursor-pointer',
|
||||
!notification.read_at
|
||||
? 'bg-paper hover:bg-primary/10'
|
||||
: 'hover:bg-hover',
|
||||
)}
|
||||
title={mainAction?.label ? mainAction.label : undefined}
|
||||
>
|
||||
{showUnreadIndicator && (
|
||||
<div className="absolute left-16 top-26 w-8 h-8 shadow rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
{notification.data.image && (
|
||||
<MixedImage
|
||||
className="w-24 h-24 flex-shrink-0 text-muted"
|
||||
src={iconMap[notification.data.image] || notification.data.image}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{notification.data.lines.map((line, index) => (
|
||||
<Line
|
||||
iconRenderer={lineIconRenderer}
|
||||
notification={notification}
|
||||
line={line}
|
||||
index={index}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
<ButtonActions
|
||||
onActionClick={onActionButtonClick}
|
||||
notification={notification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonActionsProps {
|
||||
notification: DatabaseNotification;
|
||||
onActionClick?: (
|
||||
e: React.MouseEvent,
|
||||
action: DatabaseNotificationAction,
|
||||
) => void;
|
||||
}
|
||||
function ButtonActions({notification, onActionClick}: ButtonActionsProps) {
|
||||
const {base_url} = useSettings();
|
||||
if (!notification.data.buttonActions) return null;
|
||||
|
||||
// if there's no action handler provided, assume action is internal url and render a link
|
||||
return (
|
||||
<div className="mt-12 flex items-center gap-12">
|
||||
{notification.data.buttonActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="xs"
|
||||
variant={index === 0 ? 'flat' : 'outline'}
|
||||
color={index === 0 ? 'primary' : null}
|
||||
elementType={!onActionClick ? Link : undefined}
|
||||
to={!onActionClick ? action.action.replace(base_url, '') : undefined}
|
||||
onClick={e => {
|
||||
onActionClick?.(e, action);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
common/resources/client/notifications/notification-routes.tsx
Executable file
29
common/resources/client/notifications/notification-routes.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {Route} from 'react-router-dom';
|
||||
import {AuthRoute} from '../auth/guards/auth-route';
|
||||
import {NotificationsPage} from './notifications-page';
|
||||
import {NotificationSettingsPage} from './subscriptions/notification-settings-page';
|
||||
import {ActiveWorkspaceProvider} from '../workspace/active-workspace-id-context';
|
||||
|
||||
export const NotificationRoutes = (
|
||||
<Fragment>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<ActiveWorkspaceProvider>
|
||||
<NotificationsPage />
|
||||
</ActiveWorkspaceProvider>
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications/settings"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<NotificationSettingsPage />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
96
common/resources/client/notifications/notifications-page.tsx
Executable file
96
common/resources/client/notifications/notifications-page.tsx
Executable file
@@ -0,0 +1,96 @@
|
||||
import {NotificationList} from './notification-list';
|
||||
import {useUserNotifications} from './dialog/requests/user-notifications';
|
||||
import {ProgressCircle} from '../ui/progress/progress-circle';
|
||||
import {NotificationEmptyStateMessage} from './empty-state/notification-empty-state-message';
|
||||
import {Navbar} from '../ui/navigation/navbar/navbar';
|
||||
import {Trans} from '../i18n/trans';
|
||||
import {useMarkNotificationsAsRead} from './requests/use-mark-notifications-as-read';
|
||||
import {useAuth} from '../auth/use-auth';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import {DoneAllIcon} from '../icons/material/DoneAll';
|
||||
import {StaticPageTitle} from '../seo/static-page-title';
|
||||
import {Fragment} from 'react';
|
||||
import {Footer} from '@common/ui/footer/footer';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {SettingsIcon} from '@common/icons/material/Settings';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
export function NotificationsPage() {
|
||||
const {user} = useAuth();
|
||||
const {data, isLoading} = useUserNotifications({perPage: 30});
|
||||
const hasUnread = !!user?.unread_notifications_count;
|
||||
const markAsRead = useMarkNotificationsAsRead();
|
||||
const {notif} = useSettings();
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!data) return;
|
||||
markAsRead.mutate({
|
||||
markAllAsUnread: true,
|
||||
});
|
||||
};
|
||||
|
||||
const markAsReadButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
startIcon={<DoneAllIcon />}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={markAsRead.isPending || isLoading}
|
||||
className="ml-auto"
|
||||
>
|
||||
<Trans message="Mark all as read" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Notifications" />
|
||||
</StaticPageTitle>
|
||||
<Navbar menuPosition="notifications-page" />
|
||||
<div className="container mx-auto min-h-[1000px] p-16 md:p-24">
|
||||
<div className="mb-30 flex items-center gap-24">
|
||||
<h1 className="text-3xl">
|
||||
<Trans message="Notifications" />
|
||||
</h1>
|
||||
{hasUnread && markAsReadButton}
|
||||
{notif.subs.integrated && (
|
||||
<IconButton
|
||||
className="ml-auto text-muted"
|
||||
elementType={Link}
|
||||
to="/notifications/settings"
|
||||
target="_blank"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<PageContent />
|
||||
</div>
|
||||
<Footer className="container mx-auto mt-48 p-16 md:p-24" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function PageContent() {
|
||||
const {data, isLoading} = useUserNotifications({perPage: 30});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<ProgressCircle aria-label="Loading notifications..." isIndeterminate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data?.pagination.data.length) {
|
||||
return <NotificationEmptyStateMessage />;
|
||||
}
|
||||
return (
|
||||
<NotificationList
|
||||
className="rounded border"
|
||||
notifications={data.pagination.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {apiClient, queryClient} from '../../http/query-client';
|
||||
import {useUserNotifications} from '../dialog/requests/user-notifications';
|
||||
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
|
||||
import {useBootstrapData} from '../../core/bootstrap-data/bootstrap-data-context';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
ids?: string[];
|
||||
markAllAsUnread?: boolean;
|
||||
}
|
||||
|
||||
export function useMarkNotificationsAsRead() {
|
||||
const {data, mergeBootstrapData} = useBootstrapData();
|
||||
return useMutation({
|
||||
mutationFn: (props: Payload) => UseMarkNotificationsAsRead(props),
|
||||
onSuccess: response => {
|
||||
queryClient.invalidateQueries({queryKey: useUserNotifications.key});
|
||||
if (response.unreadCount === 0) {
|
||||
mergeBootstrapData({
|
||||
user: {...data.user!, unread_notifications_count: 0},
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function UseMarkNotificationsAsRead(payload: Payload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('notifications/mark-as-read', payload)
|
||||
.then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {produce} from 'immer';
|
||||
import {useNotificationSubscriptions} from './requests/notification-subscriptions';
|
||||
import {Navbar} from '../../ui/navigation/navbar/navbar';
|
||||
import {ProgressCircle} from '../../ui/progress/progress-circle';
|
||||
import {Checkbox} from '../../ui/forms/toggle/checkbox';
|
||||
import {useUpdateNotificationSettings} from './requests/update-notification-settings';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {NotificationSubscriptionGroup} from './notification-subscription';
|
||||
import {toast} from '../../ui/toast/toast';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {message} from '../../i18n/message';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {Navigate} from 'react-router-dom';
|
||||
|
||||
type Selection = Record<string, ChannelSelection>;
|
||||
|
||||
// {email: true, mobile: true, browser: false}
|
||||
type ChannelSelection = Record<string, boolean>;
|
||||
|
||||
export function NotificationSettingsPage() {
|
||||
const {notif} = useSettings();
|
||||
const updateSettings = useUpdateNotificationSettings();
|
||||
const {data, isFetched} = useNotificationSubscriptions();
|
||||
const [selection, setSelection] = useState<Selection>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !selection) {
|
||||
const initialSelection: Selection = {};
|
||||
const initialValue: ChannelSelection = {};
|
||||
data.available_channels.forEach(channel => {
|
||||
initialValue[channel] = false;
|
||||
});
|
||||
|
||||
data.subscriptions.forEach(group => {
|
||||
group.subscriptions.forEach(subscription => {
|
||||
const backendValue = data.user_selections.find(
|
||||
s => s.notif_id === subscription.notif_id,
|
||||
);
|
||||
initialSelection[subscription.notif_id] = backendValue?.channels || {
|
||||
...initialValue,
|
||||
};
|
||||
});
|
||||
});
|
||||
setSelection(initialSelection);
|
||||
}
|
||||
}, [data, selection]);
|
||||
|
||||
if (!notif.subs.integrated || (data && data.subscriptions.length === 0)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-alt">
|
||||
<Navbar menuPosition="notifications-page" />
|
||||
{!isFetched || !data || !selection ? (
|
||||
<div className="container mx-auto my-100 flex justify-center">
|
||||
<ProgressCircle
|
||||
size="md"
|
||||
isIndeterminate
|
||||
aria-label="Loading subscriptions..."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="container mx-auto my-20 px-10 md:my-40 md:px-20">
|
||||
<div className="rounded border bg-paper px-20 pb-30 pt-20">
|
||||
{data.subscriptions.map(group => (
|
||||
<div key={group.group_name} className="mb-10 text-sm">
|
||||
<GroupRow
|
||||
key={group.group_name}
|
||||
group={group}
|
||||
allChannels={data?.available_channels}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
/>
|
||||
{group.subscriptions.map(subscription => (
|
||||
<SubscriptionRow
|
||||
key={subscription.notif_id}
|
||||
subscription={subscription}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
allChannels={data?.available_channels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="ml-10 mt-20"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={updateSettings.isPending}
|
||||
onClick={() => {
|
||||
updateSettings.mutate(
|
||||
Object.entries(selection).map(([notifId, channels]) => {
|
||||
return {notif_id: notifId, channels};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans message="Update preferences" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupRowProps {
|
||||
group: NotificationSubscriptionGroup;
|
||||
allChannels: string[];
|
||||
selection: Selection;
|
||||
setSelection: (value: Selection) => void;
|
||||
}
|
||||
function GroupRow({
|
||||
group,
|
||||
allChannels,
|
||||
selection,
|
||||
setSelection,
|
||||
}: GroupRowProps) {
|
||||
const toggleAll = (channelName: string, value: boolean) => {
|
||||
const nextState = produce(selection, draftState => {
|
||||
Object.keys(selection).forEach(notifId => {
|
||||
draftState[notifId][channelName] = value;
|
||||
});
|
||||
});
|
||||
setSelection(nextState);
|
||||
};
|
||||
|
||||
const checkboxes = (
|
||||
<div className="ml-auto flex items-center gap-40 max-md:hidden">
|
||||
{allChannels.map(channelName => {
|
||||
const allSelected = Object.values(selection).every(s => s[channelName]);
|
||||
const someSelected =
|
||||
!allSelected && Object.values(selection).some(s => s[channelName]);
|
||||
return (
|
||||
<Checkbox
|
||||
key={channelName}
|
||||
orientation="vertical"
|
||||
isIndeterminate={someSelected}
|
||||
checked={allSelected}
|
||||
onChange={async e => {
|
||||
if (channelName === 'browser') {
|
||||
const granted = await requestBrowserPermission();
|
||||
toggleAll(channelName, !granted ? false : !allSelected);
|
||||
} else {
|
||||
toggleAll(channelName, !allSelected);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trans message={channelName} />
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b p-10">
|
||||
<div className="font-medium">
|
||||
<Trans message={group.group_name} />
|
||||
</div>
|
||||
{checkboxes}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionRowProps {
|
||||
subscription: {name: string; notif_id: string};
|
||||
allChannels: string[];
|
||||
selection: Selection;
|
||||
setSelection: (value: Selection) => void;
|
||||
}
|
||||
function SubscriptionRow({
|
||||
subscription,
|
||||
allChannels,
|
||||
selection,
|
||||
setSelection,
|
||||
}: SubscriptionRowProps) {
|
||||
const notifId = subscription.notif_id;
|
||||
|
||||
const toggleChannel = (channelName: string, value: boolean) => {
|
||||
const nextState = produce(selection, draftState => {
|
||||
draftState[subscription.notif_id][channelName] = value;
|
||||
});
|
||||
setSelection(nextState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="items-center border-b py-10 pl-8 pr-10 md:flex md:pl-20">
|
||||
<div className="pb-14 font-semibold md:pb-0 md:font-normal">
|
||||
<Trans message={subscription.name} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-40">
|
||||
{allChannels.map(channelName => (
|
||||
<Checkbox
|
||||
key={channelName}
|
||||
orientation="vertical"
|
||||
checked={selection[notifId][channelName]}
|
||||
onChange={async e => {
|
||||
const newValue = !selection[notifId][channelName];
|
||||
if (channelName === 'browser') {
|
||||
const granted = await requestBrowserPermission();
|
||||
toggleChannel(channelName, !granted ? false : newValue);
|
||||
} else {
|
||||
toggleChannel(channelName, newValue);
|
||||
}
|
||||
}}
|
||||
aria-label={channelName}
|
||||
>
|
||||
<div className="md:invisible md:h-0">
|
||||
<Trans message={channelName} />
|
||||
</div>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function requestBrowserPermission(): Promise<boolean> {
|
||||
if (Notification.permission === 'granted') {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (Notification.permission === 'denied') {
|
||||
toast.danger(
|
||||
message(
|
||||
'Notifications blocked. Please enable them for this site from browser settings.',
|
||||
),
|
||||
);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Notification.requestPermission().then(permission => {
|
||||
return permission === 'granted';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface NotificationSubscription {
|
||||
id?: number;
|
||||
name: string;
|
||||
notif_id: string;
|
||||
permissions?: string[];
|
||||
channels: {[key: string]: boolean};
|
||||
}
|
||||
|
||||
export interface NotificationSubscriptionGroup {
|
||||
group_name: string;
|
||||
subscriptions: Pick<
|
||||
NotificationSubscription,
|
||||
'name' | 'notif_id' | 'permissions'
|
||||
>[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {
|
||||
NotificationSubscription,
|
||||
NotificationSubscriptionGroup,
|
||||
} from '@common/notifications/subscriptions/notification-subscription';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
export interface FetchNotificationSubscriptionsResponse
|
||||
extends BackendResponse {
|
||||
available_channels: string[];
|
||||
subscriptions: NotificationSubscriptionGroup[];
|
||||
user_selections: NotificationSubscription[];
|
||||
}
|
||||
|
||||
function fetchNotificationSubscriptions(): Promise<FetchNotificationSubscriptionsResponse> {
|
||||
return apiClient
|
||||
.get('notifications/me/subscriptions')
|
||||
.then(response => response.data);
|
||||
}
|
||||
|
||||
export function useNotificationSubscriptions() {
|
||||
return useQuery({
|
||||
queryKey: ['notification-subscriptions'],
|
||||
queryFn: () => fetchNotificationSubscriptions(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../ui/toast/toast';
|
||||
import {apiClient, queryClient} from '../../../http/query-client';
|
||||
import {message} from '../../../i18n/message';
|
||||
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
type UpdateNotificationSettingsPayload = {
|
||||
notif_id: string;
|
||||
channels: Record<string, boolean>;
|
||||
}[];
|
||||
|
||||
function UpdateNotificationSettings(
|
||||
payload: UpdateNotificationSettingsPayload,
|
||||
): Promise<Response> {
|
||||
return apiClient
|
||||
.put('notifications/me/subscriptions', {selections: payload})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
export function useUpdateNotificationSettings() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateNotificationSettingsPayload) =>
|
||||
UpdateNotificationSettings(payload),
|
||||
onSuccess: () => {
|
||||
toast(message('Updated preferences'));
|
||||
queryClient.invalidateQueries({queryKey: ['notification-subscriptions']});
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user