first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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;
}

View 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>
);
}

View 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];

View File

@@ -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>
)
}
/>
);
}

View 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

View 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} />;
}

View 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>
);
}

View 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>
);

View 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}
/>
);
}

View File

@@ -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);
}

View File

@@ -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';
});
}

View File

@@ -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'
>[];
}

View File

@@ -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,
});
}

View File

@@ -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),
});
}