import { Alert } from '@app/components' import { EmptyNote, errText, SectionTitle, Busy as Spinner } from '@app/domains/shared' import { useT } from '@app/i18n' import { ApiFailure, fmtRelative, notifyKey, useAsync } from '@app/lib' import { useResourceEvents } from '@app/services/realtime/topic' import { IconArrowUpRight as ArrowUpRight, IconCheck as Check, IconChecks as CheckCheck, IconLoader2 as Loader2, IconTrash as Trash2, } from '@tabler/icons-react' import { useCallback, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { acceptSpaceInvite, deleteNotification, listInboxNotifications, markNotificationsRead, type NotificationRow, } from '../model/api' import { refreshUnreadBadge, setNotifyForeground, watchNotifications } from '../model/live' import { inviteDecision, notificationTarget, readPayload } from '../model/target' import { NOTIFY_META } from './meta' import './notify-polish.css' /** The one invite whose write has not come back yet. */ type Pending = { id: string; doing: 'accept' | 'reject' } /** * Whether an invite row is still open. `joined` is what this page just did; * `void` is what the server says when the code no longer works, which is the * only way to learn it was accepted elsewhere, revoked, or expired. */ type InviteState = 'joined' | 'void' export interface NotifyViewProps { /** * Slugs of the spaces this account is an active member of, from the shell's * own list. An invite into a space you are already in is settled news, and * the shell already has the answer — no extra request. */ memberSpaceSlugs?: Set } /** * The personal inbox (`/app/@me/notify`). It keeps what asks something of you: * invites, which you accept or decline without leaving the page, plus * assignments and reminders that open the ticket or message behind them. * Mentions and replies are deliberately absent — they belong to the * conversation they happened in, which is where they already surface. * * While it is open the live bridge routes notifications here as silent * refreshes instead of toasts (`setNotifyForeground`); reads and deletes bump * the bell badge in place. */ export function NotifyView({ memberSpaceSlugs }: NotifyViewProps) { const { t } = useT() const navigate = useNavigate() const [tick, setTick] = useState(0) const [onlyUnread, setOnlyUnread] = useState(false) const [failed, setFailed] = useState(null) const [pending, setPending] = useState(null) // What this page has learned about each invite, beside what the row itself // says. Read state is not in here: opening an invite is not answering it. const [inviteStates, setInviteStates] = useState>({}) const list = useAsync(() => listInboxNotifications({ unread: onlyUnread || undefined }), [tick, onlyUnread], { cacheKey: notifyKey(`list:${onlyUnread ? 'unread' : 'all'}`), }) const refresh = useCallback(() => setTick((value) => value + 1), []) useResourceEvents({ topic: 'gitdata-notify', tags: ['notification-create'], onEvent: () => { list.reloadSilently() }, }) useEffect(() => { setNotifyForeground(true) const off = watchNotifications(refresh) return () => { off() setNotifyForeground(false) } }, [refresh]) const rows = list.data ?? [] /** One error surface for every write on the page. */ const run = useCallback( async (action: () => Promise) => { setFailed(null) try { await action() refresh() refreshUnreadBadge() return true } catch (error) { setFailed(errText(error)) return false } }, [refresh], ) /** Open what the row refers to; reading it is a side effect of looking. */ const open = (row: NotificationRow) => { if (!row.read_at) void run(() => markNotificationsRead({ ids: [row.id] })) const target = notificationTarget(row) if (target) navigate(target.href) } const decide = async (row: NotificationRow, _kind: 'space' | 'room', code: string, doing: 'accept' | 'reject') => { setPending({ id: row.id, doing }) setFailed(null) try { if (doing === 'reject') { await deleteNotification(row.id).catch(() => undefined) } else { await acceptSpaceInvite(code) setInviteStates((current) => ({ ...current, [row.id]: 'joined' })) await markNotificationsRead({ ids: [row.id] }).catch(() => undefined) } refresh() refreshUnreadBadge() // Joining is the whole point of accepting: land the user where they joined. if (doing === 'accept') { const target = notificationTarget(row) if (target) navigate(target.href) } } catch (error) { // 404 / 409 are not "try again": the code itself is spent, revoked or // past its expiry. The row says so in its own words, so the banner would // only repeat that in the server's English — keep it for real failures. if (doing === 'accept' && error instanceof ApiFailure && (error.code === 404 || error.code === 409)) { setInviteStates((current) => ({ ...current, [row.id]: 'void' })) } else { setFailed(errText(error)) } } finally { setPending(null) } } return (
{failed ? (
{failed}
) : null} {list.loading && rows.length === 0 ? ( ) : rows.length === 0 ? ( {onlyUnread ? t('notifications.emptyUnread') : t('notifications.emptyAll')} ) : (
    {rows.map((row) => ( open(row)} onDecide={(kind, code, doing) => void decide(row, kind, code, doing)} onDelete={() => void run(() => deleteNotification(row.id))} /> ))}
)}
) } function NotificationRowView({ row, inviteState, memberSpaceSlugs, pending, onOpen, onDecide, onDelete, }: { row: NotificationRow inviteState?: InviteState memberSpaceSlugs?: Set pending: 'accept' | 'reject' | null onOpen: () => void onDecide: (kind: 'space' | 'room', code: string, doing: 'accept' | 'reject') => void onDelete: () => void }) { const { t } = useT() const meta = NOTIFY_META[row.notification_type] const unread = !row.read_at const target = notificationTarget(row) const invite = row.notification_type === 'invite' // `null` means the row predates in-app accepting, or was mailed to an // address rather than an account: there is nothing this page can send. const decision = invite ? inviteDecision(row) : null const payload = invite ? readPayload(row) : {} const state: InviteState | undefined = inviteState ?? (decision?.kind === 'space' && payload.spaceName && memberSpaceSlugs?.has(payload.spaceName) ? 'joined' : undefined) // Being read is not being answered, so an invite stays actionable until it // is actually settled — by this page, by membership, or by a spent code. const actionable = Boolean(decision) && state === undefined // An unaccepted channel is closed to you, so an invite row is not a link // until you have joined it. const link = invite ? (state === 'joined' ? target : null) : target return (
  • { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onOpen() } } : undefined } className={`notify-row ${link ? 'is-link' : ''}`} > {row.title} {unread ? ( ) : null} {row.body ? ( {row.body} ) : null} {meta.label} · {fmtRelative(row.created_at)} {invite && !decision ? ( {t('notifications.inviteByEmail')} ) : invite && state === 'void' ? ( {t('notifications.inviteVoid')} ) : null} {state === 'joined' ? ( {t('notifications.joined')} ) : actionable && decision ? ( ) : link ? ( ) : null}
  • ) }