eeff292
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<string>
}
/**
* 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<string | null>(null)
const [pending, setPending] = useState<Pending | null>(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<Record<string, InviteState>>({})
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<unknown>) => {
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 (
<section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<div className="mx-auto w-full max-w-[720px]">
<SectionTitle title={t('notifications.title')} sub={t('notifications.sub')} />
<div className="mb-4 flex items-center gap-2">
<div className="settings-segment-group" role="group" aria-label={t('notifications.filter')}>
<button
type="button"
className="settings-segment"
aria-pressed={!onlyUnread}
onClick={() => setOnlyUnread(false)}
>
{t('common.state.all')}
</button>
<button
type="button"
className="settings-segment"
aria-pressed={onlyUnread}
onClick={() => setOnlyUnread(true)}
>
{t('notifications.unread')}
</button>
</div>
<button
type="button"
className="ml-auto inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12.5px] text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
onClick={() => void run(() => markNotificationsRead({ all: true }))}
>
<CheckCheck className="h-4 w-4" />
{t('notifications.markAllRead')}
</button>
</div>
{failed ? (
<div className="mb-3">
<Alert tone="error">{failed}</Alert>
</div>
) : null}
{list.loading && rows.length === 0 ? (
<Spinner />
) : rows.length === 0 ? (
<EmptyNote>{onlyUnread ? t('notifications.emptyUnread') : t('notifications.emptyAll')}</EmptyNote>
) : (
<ul className="divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
{rows.map((row) => (
<NotificationRowView
key={row.id}
row={row}
inviteState={inviteStates[row.id]}
memberSpaceSlugs={memberSpaceSlugs}
pending={pending?.id === row.id ? pending.doing : null}
onOpen={() => open(row)}
onDecide={(kind, code, doing) => void decide(row, kind, code, doing)}
onDelete={() => void run(() => deleteNotification(row.id))}
/>
))}
</ul>
)}
</div>
</section>
)
}
function NotificationRowView({
row,
inviteState,
memberSpaceSlugs,
pending,
onOpen,
onDecide,
onDelete,
}: {
row: NotificationRow
inviteState?: InviteState
memberSpaceSlugs?: Set<string>
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 (
<li>
<div
role={link ? 'button' : undefined}
tabIndex={link ? 0 : undefined}
onClick={link ? onOpen : undefined}
onKeyDown={
link
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpen()
}
}
: undefined
}
className={`notify-row ${link ? 'is-link' : ''}`}
>
<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="flex items-center gap-2">
<span className={`truncate text-[14px] ${unread ? 'font-semibold text-shell-ink' : 'text-shell-ink'}`}>
{row.title}
</span>
{unread ? (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-shell-ink-muted"
aria-label={t('notifications.unread')}
/>
) : null}
</span>
{row.body ? (
<span className="mt-0.5 block truncate text-[12.5px] text-shell-ink-muted">{row.body}</span>
) : null}
<span className="mt-1 block text-[11px] text-shell-ink-muted">
{meta.label} · {fmtRelative(row.created_at)}
</span>
{invite && !decision ? (
<span className="notify-note mt-1.5 block">{t('notifications.inviteByEmail')}</span>
) : invite && state === 'void' ? (
<span className="notify-note mt-1.5 block">{t('notifications.inviteVoid')}</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{state === 'joined' ? (
<span className="notify-done">
<Check className="h-3.5 w-3.5" aria-hidden />
{t('notifications.joined')}
</span>
) : actionable && decision ? (
<span className="notify-actions">
<button
type="button"
className="notify-button"
disabled={pending !== null}
onClick={(event) => {
event.stopPropagation()
onDecide(decision.kind, decision.code, 'reject')
}}
>
{pending === 'reject' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{t('notifications.reject')}
</button>
<button
type="button"
className="notify-button is-primary"
disabled={pending !== null}
onClick={(event) => {
event.stopPropagation()
onDecide(decision.kind, decision.code, 'accept')
}}
>
{pending === 'accept' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{t('notifications.accept')}
</button>
</span>
) : link ? (
<ArrowUpRight className="notify-jump h-4 w-4" aria-hidden />
) : null}
<button
type="button"
aria-label={t('notifications.delete')}
title={t('notifications.delete')}
className="workspace-icon-button -mr-1 h-8 w-8"
onClick={(event) => {
event.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</li>
)
}