eeff292
import { useT } from '@app/i18n'
import { fmtRelative } from '@app/lib'
import { IconX as X } from '@tabler/icons-react'
import { useEffect, useSyncExternalStore } from 'react'
import { useNavigate } from 'react-router-dom'
import { dismissToast, getToasts, type NotifyToast, subscribeToasts } from '../model/live'
import { notificationTarget } from '../model/target'
import { NOTIFY_META } from './meta'
import { NOTIFY_PATH } from './notify-nav-item'
const EMPTY: NotifyToast[] = []
/**
* Where a toast should take you. An invite has no destination until you have
* accepted it, so it goes to the inbox that holds the buttons; everything else
* opens the ticket or message it is about, which is faster than a detour
* through the notification list.
*/
function toastHref(notification: NotifyToast['notification']): string {
if (notification.notification_type === 'invite') return NOTIFY_PATH
return notificationTarget(notification)?.href ?? NOTIFY_PATH
}
/**
* Off-page toast host for live notifications: mount once beside the workspace
* shell. The bridge only enqueues toasts while the inbox is *not* foreground,
* so opening the page is all it takes to switch from toasts to refreshes.
* Mentions and replies arrive here too: they are not listed in the inbox, so
* this is the only place they surface at all.
*/
export function NotifyToaster() {
const { t } = useT()
const toasts = useSyncExternalStore(subscribeToasts, getToasts, () => EMPTY)
if (toasts.length === 0) return null
return (
<div
role="region"
aria-label={t('notifications.region')}
className="pointer-events-none fixed bottom-5 right-5 z-50 flex w-[340px] max-w-[calc(100vw-2.5rem)] flex-col gap-2"
>
{toasts.map((toast) => (
<ToastCard key={toast.key} toast={toast} />
))}
</div>
)
}
function ToastCard({ toast }: { toast: NotifyToast }) {
const { t } = useT()
const navigate = useNavigate()
useEffect(() => {
const timer = setTimeout(() => dismissToast(toast.key), 6000)
return () => clearTimeout(timer)
}, [toast.key])
const { notification } = toast
const meta = NOTIFY_META[notification.notification_type]
return (
<div className="pointer-events-auto flex items-start gap-3 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3 shadow-xl">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-3 text-left"
onClick={() => {
dismissToast(toast.key)
navigate(toastHref(notification))
}}
>
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-shell-tile text-shell-ink-muted">
<meta.icon className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold">{notification.title}</span>
{notification.body ? (
<span className="mt-0.5 block truncate text-[12.5px] text-shell-ink-muted">{notification.body}</span>
) : null}
<span className="mt-0.5 block text-[11px] text-shell-ink-muted">
{meta.label} · {fmtRelative(notification.created_at)}
</span>
</span>
</button>
<button
type="button"
aria-label={t('notifications.closeToast')}
className="workspace-icon-button -mr-1 h-7 w-7"
onClick={() => dismissToast(toast.key)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}