250 lines
7.1 KiB
TypeScript
250 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Database } from '@kit/supabase/database';
|
|
import { Button } from '@kit/ui/button';
|
|
import { If } from '@kit/ui/if';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
|
import { Separator } from '@kit/ui/separator';
|
|
import { cn } from '@kit/ui/utils';
|
|
|
|
import { useDismissNotification, useFetchNotifications } from '../hooks';
|
|
|
|
type Notification = Database['public']['Tables']['notifications']['Row'];
|
|
|
|
type PartialNotification = Pick<
|
|
Notification,
|
|
'id' | 'body' | 'dismissed' | 'type' | 'created_at' | 'link'
|
|
>;
|
|
|
|
export function NotificationsPopover(params: {
|
|
realtime: boolean;
|
|
accountIds: string[];
|
|
onClick?: (notification: PartialNotification) => void;
|
|
}) {
|
|
const { i18n, t } = useTranslation();
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [notifications, setNotifications] = useState<PartialNotification[]>([]);
|
|
|
|
const onNotifications = useCallback(
|
|
(notifications: PartialNotification[]) => {
|
|
setNotifications((existing) => {
|
|
const unique = new Set(existing.map((notification) => notification.id));
|
|
|
|
const notificationsFiltered = notifications.filter(
|
|
(notification) => !unique.has(notification.id),
|
|
);
|
|
|
|
return [...notificationsFiltered, ...existing];
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const dismissNotification = useDismissNotification();
|
|
|
|
useFetchNotifications({
|
|
onNotifications,
|
|
accountIds: params.accountIds,
|
|
realtime: params.realtime,
|
|
});
|
|
|
|
const timeAgo = (createdAt: string) => {
|
|
const date = new Date(createdAt);
|
|
|
|
let time: number;
|
|
|
|
const daysAgo = Math.floor(
|
|
(new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
|
|
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
|
|
numeric: 'auto',
|
|
});
|
|
|
|
if (daysAgo < 1) {
|
|
time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60));
|
|
|
|
if (time < 5) {
|
|
return t('common:justNow');
|
|
}
|
|
|
|
if (time < 60) {
|
|
return formatter.format(-time, 'minute');
|
|
}
|
|
|
|
const hours = Math.floor(time / 60);
|
|
|
|
return formatter.format(-hours, 'hour');
|
|
}
|
|
|
|
const unit = (() => {
|
|
const minutesAgo = Math.floor(
|
|
(new Date().getTime() - date.getTime()) / (1000 * 60),
|
|
);
|
|
|
|
if (minutesAgo <= 60) {
|
|
return 'minute';
|
|
}
|
|
|
|
if (daysAgo <= 1) {
|
|
return 'hour';
|
|
}
|
|
|
|
if (daysAgo <= 30) {
|
|
return 'day';
|
|
}
|
|
|
|
if (daysAgo <= 365) {
|
|
return 'month';
|
|
}
|
|
|
|
return 'year';
|
|
})();
|
|
|
|
const text = formatter.format(-daysAgo, unit);
|
|
|
|
return text.slice(0, 1).toUpperCase() + text.slice(1);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
setNotifications([]);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<Popover modal open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button className={'relative h-9 w-9'} variant={'ghost'}>
|
|
<Bell className={'min-h-4 min-w-4'} />
|
|
|
|
<span
|
|
className={cn(
|
|
`fade-in animate-in zoom-in absolute right-1 top-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
|
|
{
|
|
hidden: !notifications.length,
|
|
},
|
|
)}
|
|
>
|
|
{notifications.length}
|
|
</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent
|
|
className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'}
|
|
align={'start'}
|
|
collisionPadding={20}
|
|
sideOffset={10}
|
|
>
|
|
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
|
|
{t('common:notifications')}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<If condition={!notifications.length}>
|
|
<div className={'px-3 py-2 text-sm'}>
|
|
{t('common:noNotifications')}
|
|
</div>
|
|
</If>
|
|
|
|
<div
|
|
className={
|
|
'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800'
|
|
}
|
|
>
|
|
{notifications.map((notification) => {
|
|
const maxChars = 100;
|
|
|
|
let body = t(notification.body, {
|
|
defaultValue: notification.body,
|
|
});
|
|
|
|
if (body.length > maxChars) {
|
|
body = body.substring(0, maxChars) + '...';
|
|
}
|
|
|
|
const Icon = () => {
|
|
switch (notification.type) {
|
|
case 'warning':
|
|
return <TriangleAlert className={'h-4 text-yellow-500'} />;
|
|
case 'error':
|
|
return <CircleAlert className={'text-destructive h-4'} />;
|
|
default:
|
|
return <Info className={'h-4 text-blue-500'} />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={notification.id.toString()}
|
|
className={cn(
|
|
'min-h-18 flex flex-col items-start justify-center gap-y-1 px-3 py-2',
|
|
)}
|
|
onClick={() => {
|
|
if (params.onClick) {
|
|
params.onClick(notification);
|
|
}
|
|
}}
|
|
>
|
|
<div className={'flex w-full items-start justify-between'}>
|
|
<div
|
|
className={'flex items-start justify-start gap-x-3 py-2'}
|
|
>
|
|
<div className={'py-0.5'}>
|
|
<Icon />
|
|
</div>
|
|
|
|
<div className={'flex flex-col space-y-1'}>
|
|
<div className={'text-sm'}>
|
|
<If condition={notification.link} fallback={body}>
|
|
{(link) => (
|
|
<a href={link} className={'hover:underline'}>
|
|
{body}
|
|
</a>
|
|
)}
|
|
</If>
|
|
</div>
|
|
|
|
<span className={'text-muted-foreground text-xs'}>
|
|
{timeAgo(notification.created_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={'py-2'}>
|
|
<Button
|
|
className={'max-h-6 max-w-6'}
|
|
size={'icon'}
|
|
variant={'ghost'}
|
|
onClick={() => {
|
|
setNotifications((existing) => {
|
|
return existing.filter(
|
|
(existingNotification) =>
|
|
existingNotification.id !== notification.id,
|
|
);
|
|
});
|
|
|
|
return dismissNotification(notification.id);
|
|
}}
|
|
>
|
|
<XIcon className={'h-3'} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|