feat(web/app): add the notifications domain
Port the inbox, unread badge and toaster. The list subscribes to notification-create and revalidates silently, and only space invitations can be accepted from the inbox now.
ZhenYi committed
Added view/app/src/domains/notifications/model/api.ts view/app/src/domains/notifications/model/api.ts +88 −0 View view/app/src/domains/notifications/model/api.ts
@@ -0,0 +1,88 @@
1/**
2 * Personal notification endpoints (`/me/notifications*`). The generated client
3 * inlines the paged row type, so `NotificationRow` is carved out of the list
4 * envelope instead of being hand-written; keep it in sync with `WsNotification`.
5 */
6
7import { unwrapApi } from '@app/lib'
8import type { EnvelopePageNotificationDto, MarkNotificationsReadCmd, NotificationType } from '@app/services/api'
9import Apis from '@app/services/api'
10
11export type NotificationRow = NonNullable<NonNullable<EnvelopePageNotificationDto['data']>['items']>[number]
12
13export const NOTIFICATIONS_PAGE_LIMIT = 50
14
15export interface NotificationQuery {
16 offset?: number
17 unread?: boolean
18 type?: NotificationType
19}
20
21/**
22 * Unset filters are omitted rather than sent as `null`: Alova serialises a
23 * `null` query value to the literal string `"null"`, which fails the backend's
24 * `unread: Option<bool>` parse outright (400) and would silently match a branch
25 * or cursor named "null" where the type is a string. `false` is a real filter
26 * and must survive, hence the explicit `undefined` test.
27 */
28export async function listNotifications(query: NotificationQuery = {}): Promise<NotificationRow[]> {
29 const page = await unwrapApi<{ items?: NotificationRow[] }>(
30 Apis.notifications.list_notifications({
31 params: {
32 limit: NOTIFICATIONS_PAGE_LIMIT,
33 offset: query.offset ?? 0,
34 ...(query.unread === undefined ? {} : { unread: query.unread }),
35 ...(query.type ? { type: query.type } : {}),
36 },
37 }),
38 )
39 return page?.items ?? []
40}
41
42export const markNotificationsRead = (cmd: MarkNotificationsReadCmd) =>
43 unwrapApi(Apis.account.mark_notifications_read({ data: cmd }))
44
45export const deleteNotification = (notificationId: string) =>
46 unwrapApi(Apis.notifications.delete_notification({ pathParams: { notification_id: notificationId } }))
47
48/*
49 * ------------------------------------------------------------------------
50 * The inbox is a *personal* list: it holds the things that ask something of
51 * you (an invite, an assignment, a reminder) and leaves the conversation
52 * traffic where it happened. Mentions and replies are addressed to a channel
53 * or a ticket, and both already surface there, so keeping them here would
54 * turn the page into a second, noisier copy of the transcript.
55 * ------------------------------------------------------------------------
56 */
57
58/** Kinds the inbox hides; they still arrive as realtime toasts. */
59export const INBOX_HIDDEN_TYPES: readonly NotificationType[] = ['mention', 'reply']
60
61export function isInboxNotification(row: Pick<NotificationRow, 'notification_type'>): boolean {
62 return !INBOX_HIDDEN_TYPES.includes(row.notification_type)
63}
64
65/** The list the inbox renders: the server has no "not one of" filter. */
66export async function listInboxNotifications(query: NotificationQuery = {}): Promise<NotificationRow[]> {
67 const rows = await listNotifications(query)
68 return rows.filter(isInboxNotification)
69}
70
71/**
72 * Unread total *for this page*, so the bell never promises more rows than the
73 * inbox shows. Capped at one page: `capped` lets the badge read "50+" instead
74 * of lying with a smaller number.
75 */
76export async function inboxUnreadCount(): Promise<{ count: number; capped: boolean }> {
77 const rows = await listInboxNotifications({ unread: true })
78 return { count: rows.length, capped: rows.length >= NOTIFICATIONS_PAGE_LIMIT }
79}
80
81/* ------------------------------------------------------------------ invites */
82
83/**
84 * Accepting an invite is the same call the invite email's link makes: the
85 * notification carries the very code that email does, so the inbox can act on
86 * an invite instead of forwarding you to your mailbox.
87 */
88export const acceptSpaceInvite = (token: string) => unwrapApi(Apis.space.space_invite_redeem({ data: { token } }))
Added view/app/src/domains/notifications/model/live.ts view/app/src/domains/notifications/model/live.ts +95 −0 View view/app/src/domains/notifications/model/live.ts
@@ -0,0 +1,95 @@
1/**
2 * Realtime bridge for `notification` user events (protocol.ts). One hub
3 * subscription serves the whole app:
4 * - the inbox page registers as *foreground* and refreshes its list in place;
5 * - anywhere else the event is turned into a toast.
6 *
7 * A framework-free module store (same shape as the ws hub itself): the socket
8 * outlives any single view, and StrictMode's double mount must not open a
9 * second one.
10 */
11import { getRealtimeHub, isNotificationUserEvent, type WsNotification } from '@app/services/realtime'
12
13export interface NotifyToast {
14 key: number
15 notification: WsNotification
16}
17
18type LiveListener = (notification: WsNotification) => void
19
20const liveListeners = new Set<LiveListener>()
21const toastListeners = new Set<() => void>()
22const badgeListeners = new Set<() => void>()
23
24let foreground = false
25let toasts: NotifyToast[] = []
26let toastSeq = 0
27let bridge: (() => void) | null = null
28
29/** The session socket, started without any room intent of its own. */
30function ensureBridge(): void {
31 if (bridge) return
32 const hub = getRealtimeHub()
33 hub.ensureClient().start()
34 bridge = hub.onUserEvent((event) => {
35 if (!isNotificationUserEvent(event)) return
36 const notification = event.notification
37 for (const listener of [...liveListeners]) listener(notification)
38 refreshUnreadBadge()
39 // The inbox already refetches on its own; only off-page events toast.
40 if (!foreground) pushToast(notification)
41 })
42}
43
44export function watchNotifications(listener: LiveListener): () => void {
45 ensureBridge()
46 liveListeners.add(listener)
47 return () => {
48 liveListeners.delete(listener)
49 }
50}
51
52/** Called by the inbox page for its mounted lifetime. */
53export function setNotifyForeground(on: boolean): void {
54 foreground = on
55}
56
57/* ------------------------------------------------------------------ toasts */
58
59function emitToasts(): void {
60 for (const listener of [...toastListeners]) listener()
61}
62
63function pushToast(notification: WsNotification): void {
64 toastSeq += 1
65 toasts = [...toasts, { key: toastSeq, notification }].slice(-3)
66 emitToasts()
67}
68
69export function dismissToast(key: number): void {
70 const next = toasts.filter((toast) => toast.key !== key)
71 if (next.length === toasts.length) return
72 toasts = next
73 emitToasts()
74}
75
76export function getToasts(): NotifyToast[] {
77 return toasts
78}
79
80export function subscribeToasts(listener: () => void): () => void {
81 toastListeners.add(listener)
82 return () => toastListeners.delete(listener)
83}
84
85/* ------------------------------------------------------------- unread badge */
86
87/** Bell refetch signal: live events and the inbox's own read/delete writes. */
88export function refreshUnreadBadge(): void {
89 for (const listener of [...badgeListeners]) listener()
90}
91
92export function watchUnreadBadge(listener: () => void): () => void {
93 badgeListeners.add(listener)
94 return () => badgeListeners.delete(listener)
95}
Added view/app/src/domains/notifications/model/target.ts view/app/src/domains/notifications/model/target.ts +127 −0 View view/app/src/domains/notifications/model/target.ts
@@ -0,0 +1,127 @@
1/**
2 * Notification payloads are a free JSON column (`serde_json::Value` on the
3 * server, `null` in the generated client), so nothing types them. These are
4 * the shapes the writers in the API actually emit:
5 *
6 * ```text
7 * mention / reply issue space_name board_id issue_id comment_id autoincrement_id
8 * mention channel room_id message_id seq thread_id space_name
9 * assignment space_name board_id issue_id autoincrement_id
10 * invite channel room_id invite_id accept_code space_name
11 * invite space space_id space_name invite_id accept_code
12 * system reminder room_id message_id space_name
13 * ```
14 *
15 * `space_name` is always the route slug (`lower_name`), which is what
16 * `/app/{space}/…` addresses.
17 */
18import { t } from '@app/i18n'
19
20/**
21 * A notification as far as routing is concerned: the type picks the shape and
22 * `data` is the server's free JSON column. Both the HTTP row and the WS event
23 * satisfy it, so a toast and an inbox row resolve to the same destination.
24 */
25export interface NotificationLike {
26 notification_type: string
27 data?: unknown
28}
29
30export interface NotificationPayload {
31 spaceName?: string
32 requestId?: string
33 applicantId?: string
34 kind?: string
35 boardId?: string
36 issueNumber?: string
37 roomId?: string
38 messageId?: string
39 inviteId?: string
40 acceptCode?: string
41}
42
43/** Where a notification came from, once its payload is read. */
44export type InviteKind = 'space' | 'room' | 'unknown'
45
46function str(source: Record<string, unknown>, key: string): string | undefined {
47 const value = source[key]
48 if (typeof value === 'string' && value) return value
49 // `autoincrement_id` is a JSON number on the server.
50 if (typeof value === 'number' && Number.isFinite(value)) return String(value)
51 return undefined
52}
53
54export function readPayload(row: { data?: unknown }): NotificationPayload {
55 const raw = row.data as unknown
56 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
57 const source = raw as Record<string, unknown>
58 return {
59 spaceName: str(source, 'space_name'),
60 boardId: str(source, 'board_id'),
61 issueNumber: str(source, 'autoincrement_id'),
62 roomId: str(source, 'room_id'),
63 messageId: str(source, 'message_id'),
64 inviteId: str(source, 'invite_id'),
65 acceptCode: str(source, 'accept_code'),
66 requestId: str(source, 'request_id'),
67 applicantId: str(source, 'applicant_id'),
68 kind: str(source, 'kind'),
69 }
70}
71
72export interface NotificationTarget {
73 href: string
74 /** Verb for the row's jump affordance, phrased for what it opens. */
75 label: string
76}
77
78/** A space's route segment. `@me` is a literal we own, so it must not be
79 * encoded: the shell matches the personal scope off the raw pathname. */
80const scope = (name?: string) => (name ? encodeURIComponent(name) : '@me')
81
82const roomHref = (payload: NotificationPayload) => `/app/${scope(payload.spaceName)}/chat/${payload.roomId}`
83
84/**
85 * The screen this notification is about, or `null` when it points at nothing
86 * (an email-only invite, a row written before payloads carried a slug). Rows
87 * without a target stay readable but are not clickable.
88 */
89export function notificationTarget(row: NotificationLike): NotificationTarget | null {
90 const payload = readPayload(row)
91 // A join request asks a space manager for a decision, so it lands on the
92 // settings section that can make it rather than on the space's chat column.
93 if (payload.kind === 'join_request_submitted' && payload.spaceName) {
94 return {
95 href: `/app/${scope(payload.spaceName)}?settings=join-requests`,
96 label: t('notifications.target.joinRequests'),
97 }
98 }
99 // An issue row opens the ticket itself, not the board's column list.
100 if (payload.spaceName && payload.boardId && payload.issueNumber) {
101 return {
102 href: `/app/${scope(payload.spaceName)}/board/${payload.boardId}?issue=${encodeURIComponent(payload.issueNumber)}`,
103 label: t('notifications.target.issue', { number: payload.issueNumber }),
104 }
105 }
106 if (payload.roomId) {
107 const jump = payload.messageId ? `?jump=${encodeURIComponent(payload.messageId)}` : ''
108 return { href: `${roomHref(payload)}${jump}`, label: t('notifications.target.room') }
109 }
110 if (payload.spaceName) {
111 return { href: `/app/${scope(payload.spaceName)}`, label: t('notifications.target.space') }
112 }
113 return null
114}
115
116/**
117 * What an invite row can act on: which accept / reject endpoint to call, with
118 * the credential it needs. `null` means the row predates in-app accepting or
119 * was mailed to an address instead of an account, so there is nothing to send.
120 */
121export function inviteDecision(row: NotificationLike): { kind: Exclude<InviteKind, 'unknown'>; code: string } | null {
122 const payload = readPayload(row)
123 if (!payload.acceptCode) return null
124 if (payload.roomId) return { kind: 'room', code: payload.acceptCode }
125 if (payload.spaceName) return { kind: 'space', code: payload.acceptCode }
126 return null
127}
Added view/app/src/domains/notifications/model/use-unread-count.ts view/app/src/domains/notifications/model/use-unread-count.ts +29 −0 View view/app/src/domains/notifications/model/use-unread-count.ts
@@ -0,0 +1,29 @@
1import { notifyKey, useAsync } from '@app/lib'
2import { useEffect, useState } from 'react'
3import { inboxUnreadCount, isInboxNotification } from './api'
4import { watchNotifications, watchUnreadBadge } from './live'
5
6/**
7 * Live unread total for the inbox: an HTTP baseline plus refetches driven by
8 * the realtime bridge and by the page's own read/delete writes.
9 *
10 * Two deliberate couplings to what `/app/@me/notify` actually shows:
11 * the count is the inbox's own filtered list, and a live mention or reply does
12 * not bump it at all — those rows are not there to be found.
13 */
14export function useUnreadNotificationCount(): { count: number; capped: boolean } {
15 const [tick, setTick] = useState(0)
16 useEffect(() => {
17 const refresh = () => setTick((value) => value + 1)
18 const offLive = watchNotifications((notification) => {
19 if (isInboxNotification(notification)) refresh()
20 })
21 const offBadge = watchUnreadBadge(refresh)
22 return () => {
23 offLive()
24 offBadge()
25 }
26 }, [])
27 const value = useAsync(inboxUnreadCount, [tick], { cacheKey: notifyKey('unread') })
28 return value.data ?? { count: 0, capped: false }
29}
Added view/app/src/domains/notifications/ui/meta.ts view/app/src/domains/notifications/ui/meta.ts +42 −0 View view/app/src/domains/notifications/ui/meta.ts
@@ -0,0 +1,42 @@
1import { t } from '@app/i18n'
2import type { NotificationType } from '@app/services/api'
3import { AtSign, Bell, Flag, MessageSquare, UserPlus, Users } from 'lucide-react'
4
5export const NOTIFY_META: Record<NotificationType, { label: string; icon: typeof AtSign }> = {
6 mention: {
7 get label() {
8 return t('notifications.type.mention')
9 },
10 icon: AtSign,
11 },
12 reply: {
13 get label() {
14 return t('notifications.type.reply')
15 },
16 icon: MessageSquare,
17 },
18 follow: {
19 get label() {
20 return t('notifications.type.follow')
21 },
22 icon: Users,
23 },
24 invite: {
25 get label() {
26 return t('notifications.type.invite')
27 },
28 icon: UserPlus,
29 },
30 assignment: {
31 get label() {
32 return t('notifications.type.assignment')
33 },
34 icon: Flag,
35 },
36 system: {
37 get label() {
38 return t('notifications.type.system')
39 },
40 icon: Bell,
41 },
42}
Added view/app/src/domains/notifications/ui/notify-nav-item.tsx view/app/src/domains/notifications/ui/notify-nav-item.tsx +38 −0 View view/app/src/domains/notifications/ui/notify-nav-item.tsx
@@ -0,0 +1,38 @@
1import { useT } from '@app/i18n'
2import { Bell } from 'lucide-react'
3import { Link, useLocation } from 'react-router-dom'
4import { useUnreadNotificationCount } from '../model/use-unread-count'
5
6export const NOTIFY_PATH = '/app/@me/notify'
7
8/**
9 * Pinned "通知" row on top of the personal space's workspace list, with the
10 * live unread badge beside it. Styling and active marking follow the
11 * sidebar's own rows; the route match is read off the location directly.
12 */
13export function NotifyNavItem() {
14 const { t } = useT()
15 const { pathname } = useLocation()
16 const { count, capped } = useUnreadNotificationCount()
17 const active = pathname === NOTIFY_PATH
18 return (
19 <Link
20 to={NOTIFY_PATH}
21 title={t('notifications.title')}
22 aria-current={active ? 'page' : undefined}
23 className={`workspace-nav-row flex h-8 items-center gap-1.5 rounded-md pl-2 pr-2 text-[14px] font-medium ${
24 active ? 'bg-shell-active text-shell-ink' : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
25 }`}
26 >
27 <span className="shrink-0 text-shell-ink-muted">
28 <Bell className="h-4 w-4" aria-hidden />
29 </span>
30 <span className="truncate">{t('notifications.title')}</span>
31 {count > 0 ? (
32 <span className="ml-auto shrink-0 rounded-full bg-shell-tile px-1.5 text-[11px] font-semibold leading-[20px] text-shell-ink">
33 {capped ? '50+' : count > 99 ? '99+' : count}
34 </span>
35 ) : null}
36 </Link>
37 )
38}
Added view/app/src/domains/notifications/ui/notify-polish.css view/app/src/domains/notifications/ui/notify-polish.css +98 −0 View view/app/src/domains/notifications/ui/notify-polish.css
@@ -0,0 +1,98 @@
1/*
2 * Personal inbox: a list of small decisions, so the row itself carries the
3 * interaction and nothing else competes with it. Unread is marked by weight
4 * and a hairline dot, never by a colour swatch; the one solid fill on the page
5 * is the accept button, because that is the only thing being asked of you.
6 */
7.notify-row {
8 display: flex;
9 align-items: flex-start;
10 gap: 12px;
11 padding: 14px 16px;
12 transition: background 150ms;
13}
14.notify-row.is-link {
15 cursor: pointer;
16}
17.notify-row.is-link:hover {
18 background: color-mix(in srgb, var(--shell-ink) 4%, transparent);
19}
20.notify-row.is-open {
21 background: var(--shell-field);
22}
23
24/* The arrow only appears where the row is a link, so a static row stays quiet. */
25.notify-jump {
26 color: var(--shell-ink-muted);
27 opacity: 0;
28 transform: translateX(-4px);
29 transition:
30 opacity 150ms ease,
31 transform 180ms cubic-bezier(0.22, 0.68, 0.28, 1);
32}
33.notify-row.is-link:hover .notify-jump,
34.notify-row.is-link:focus-visible .notify-jump {
35 opacity: 1;
36 transform: none;
37}
38
39.notify-actions {
40 display: flex;
41 flex-wrap: wrap;
42 justify-content: flex-end;
43 gap: 8px;
44}
45
46.notify-button {
47 display: inline-flex;
48 align-items: center;
49 gap: 6px;
50 min-height: 30px;
51 padding: 4px 12px;
52 border: 1px solid var(--shell-hairline);
53 border-radius: 9px;
54 background: var(--shell-surface);
55 color: var(--shell-ink-muted);
56 font-size: 12.5px;
57 font-weight: 500;
58 white-space: nowrap;
59 transition:
60 background 150ms,
61 border-color 150ms,
62 color 150ms,
63 filter 150ms;
64}
65.notify-button:hover:not(:disabled) {
66 color: var(--shell-ink);
67 border-color: var(--shell-ink-muted);
68 background: var(--shell-active);
69}
70.notify-button:disabled {
71 opacity: 0.6;
72 cursor: default;
73}
74.notify-button.is-primary {
75 border-color: transparent;
76 background: var(--shell-action);
77 color: #fff;
78 font-weight: 600;
79}
80.notify-button.is-primary:hover:not(:disabled) {
81 background: var(--shell-action);
82 color: #fff;
83 filter: brightness(1.12);
84}
85
86/* A row that has already been dealt with: settled, and out of the way. */
87.notify-done {
88 display: inline-flex;
89 align-items: center;
90 gap: 5px;
91 color: var(--shell-ink-muted);
92 font-size: 12.5px;
93}
94.notify-note {
95 color: var(--shell-ink-muted);
96 font-size: 12px;
97 line-height: 1.5;
98}
Added view/app/src/domains/notifications/ui/notify-toaster.tsx view/app/src/domains/notifications/ui/notify-toaster.tsx +90 −0 View view/app/src/domains/notifications/ui/notify-toaster.tsx
@@ -0,0 +1,90 @@
1import { useT } from '@app/i18n'
2import { fmtRelative } from '@app/lib'
3import { X } from 'lucide-react'
4import { useEffect, useSyncExternalStore } from 'react'
5import { useNavigate } from 'react-router-dom'
6import { dismissToast, getToasts, type NotifyToast, subscribeToasts } from '../model/live'
7import { notificationTarget } from '../model/target'
8import { NOTIFY_META } from './meta'
9import { NOTIFY_PATH } from './notify-nav-item'
10
11const EMPTY: NotifyToast[] = []
12
13/**
14 * Where a toast should take you. An invite has no destination until you have
15 * accepted it, so it goes to the inbox that holds the buttons; everything else
16 * opens the ticket or message it is about, which is faster than a detour
17 * through the notification list.
18 */
19function toastHref(notification: NotifyToast['notification']): string {
20 if (notification.notification_type === 'invite') return NOTIFY_PATH
21 return notificationTarget(notification)?.href ?? NOTIFY_PATH
22}
23
24/**
25 * Off-page toast host for live notifications: mount once beside the workspace
26 * shell. The bridge only enqueues toasts while the inbox is *not* foreground,
27 * so opening the page is all it takes to switch from toasts to refreshes.
28 * Mentions and replies arrive here too: they are not listed in the inbox, so
29 * this is the only place they surface at all.
30 */
31export function NotifyToaster() {
32 const { t } = useT()
33 const toasts = useSyncExternalStore(subscribeToasts, getToasts, () => EMPTY)
34 if (toasts.length === 0) return null
35 return (
36 <div
37 role="region"
38 aria-label={t('notifications.region')}
39 className="pointer-events-none fixed bottom-5 right-5 z-50 flex w-[340px] max-w-[calc(100vw-2.5rem)] flex-col gap-2"
40 >
41 {toasts.map((toast) => (
42 <ToastCard key={toast.key} toast={toast} />
43 ))}
44 </div>
45 )
46}
47
48function ToastCard({ toast }: { toast: NotifyToast }) {
49 const { t } = useT()
50 const navigate = useNavigate()
51 useEffect(() => {
52 const timer = setTimeout(() => dismissToast(toast.key), 6000)
53 return () => clearTimeout(timer)
54 }, [toast.key])
55 const { notification } = toast
56 const meta = NOTIFY_META[notification.notification_type]
57 return (
58 <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">
59 <button
60 type="button"
61 className="flex min-w-0 flex-1 items-start gap-3 text-left"
62 onClick={() => {
63 dismissToast(toast.key)
64 navigate(toastHref(notification))
65 }}
66 >
67 <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">
68 <meta.icon className="h-4 w-4" aria-hidden />
69 </span>
70 <span className="min-w-0 flex-1">
71 <span className="block truncate text-[13.5px] font-semibold">{notification.title}</span>
72 {notification.body ? (
73 <span className="mt-0.5 block truncate text-[12.5px] text-shell-ink-muted">{notification.body}</span>
74 ) : null}
75 <span className="mt-0.5 block text-[11px] text-shell-ink-muted">
76 {meta.label} · {fmtRelative(notification.created_at)}
77 </span>
78 </span>
79 </button>
80 <button
81 type="button"
82 aria-label={t('notifications.closeToast')}
83 className="workspace-icon-button -mr-1 h-7 w-7"
84 onClick={() => dismissToast(toast.key)}
85 >
86 <X className="h-3.5 w-3.5" />
87 </button>
88 </div>
89 )
90}
Added view/app/src/domains/notifications/ui/notify-view.tsx view/app/src/domains/notifications/ui/notify-view.tsx +335 −0 View view/app/src/domains/notifications/ui/notify-view.tsx
@@ -0,0 +1,335 @@
1import { Alert } from '@app/components'
2import { EmptyNote, errText, SectionTitle, Busy as Spinner } from '@app/domains/shared'
3import { useResourceEvents } from '@app/services/realtime/topic'
4import { useT } from '@app/i18n'
5import { ApiFailure, fmtRelative, notifyKey, useAsync } from '@app/lib'
6import { ArrowUpRight, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react'
7import { useCallback, useEffect, useState } from 'react'
8import { useNavigate } from 'react-router-dom'
9import {
10 acceptSpaceInvite,
11 deleteNotification,
12 listInboxNotifications,
13 markNotificationsRead,
14 type NotificationRow,
15} from '../model/api'
16import { refreshUnreadBadge, setNotifyForeground, watchNotifications } from '../model/live'
17import { inviteDecision, notificationTarget, readPayload } from '../model/target'
18import { NOTIFY_META } from './meta'
19import './notify-polish.css'
20
21/** The one invite whose write has not come back yet. */
22type Pending = { id: string; doing: 'accept' | 'reject' }
23
24/**
25 * Whether an invite row is still open. `joined` is what this page just did;
26 * `void` is what the server says when the code no longer works, which is the
27 * only way to learn it was accepted elsewhere, revoked, or expired.
28 */
29type InviteState = 'joined' | 'void'
30
31export interface NotifyViewProps {
32 /**
33 * Slugs of the spaces this account is an active member of, from the shell's
34 * own list. An invite into a space you are already in is settled news, and
35 * the shell already has the answer — no extra request.
36 */
37 memberSpaceSlugs?: Set<string>
38}
39
40/**
41 * The personal inbox (`/app/@me/notify`). It keeps what asks something of you:
42 * invites, which you accept or decline without leaving the page, plus
43 * assignments and reminders that open the ticket or message behind them.
44 * Mentions and replies are deliberately absent — they belong to the
45 * conversation they happened in, which is where they already surface.
46 *
47 * While it is open the live bridge routes notifications here as silent
48 * refreshes instead of toasts (`setNotifyForeground`); reads and deletes bump
49 * the bell badge in place.
50 */
51export function NotifyView({ memberSpaceSlugs }: NotifyViewProps) {
52 const { t } = useT()
53 const navigate = useNavigate()
54 const [tick, setTick] = useState(0)
55 const [onlyUnread, setOnlyUnread] = useState(false)
56 const [failed, setFailed] = useState<string | null>(null)
57 const [pending, setPending] = useState<Pending | null>(null)
58 // What this page has learned about each invite, beside what the row itself
59 // says. Read state is not in here: opening an invite is not answering it.
60 const [inviteStates, setInviteStates] = useState<Record<string, InviteState>>({})
61 const list = useAsync(() => listInboxNotifications({ unread: onlyUnread || undefined }), [tick, onlyUnread], {
62 cacheKey: notifyKey(`list:${onlyUnread ? 'unread' : 'all'}`),
63 })
64 const refresh = useCallback(() => setTick((value) => value + 1), [])
65 useResourceEvents({
66 topic: 'gitdata-notify',
67 tags: ['notification-create'],
68 onEvent: () => {
69 list.reloadSilently()
70 },
71 })
72
73 useEffect(() => {
74 setNotifyForeground(true)
75 const off = watchNotifications(refresh)
76 return () => {
77 off()
78 setNotifyForeground(false)
79 }
80 }, [refresh])
81
82 const rows = list.data ?? []
83
84 /** One error surface for every write on the page. */
85 const run = useCallback(
86 async (action: () => Promise<unknown>) => {
87 setFailed(null)
88 try {
89 await action()
90 refresh()
91 refreshUnreadBadge()
92 return true
93 } catch (error) {
94 setFailed(errText(error))
95 return false
96 }
97 },
98 [refresh],
99 )
100
101 /** Open what the row refers to; reading it is a side effect of looking. */
102 const open = (row: NotificationRow) => {
103 if (!row.read_at) void run(() => markNotificationsRead({ ids: [row.id] }))
104 const target = notificationTarget(row)
105 if (target) navigate(target.href)
106 }
107
108 const decide = async (row: NotificationRow, _kind: 'space' | 'room', code: string, doing: 'accept' | 'reject') => {
109 setPending({ id: row.id, doing })
110 setFailed(null)
111 try {
112 if (doing === 'reject') {
113 await deleteNotification(row.id).catch(() => undefined)
114 } else {
115 await acceptSpaceInvite(code)
116 setInviteStates((current) => ({ ...current, [row.id]: 'joined' }))
117 await markNotificationsRead({ ids: [row.id] }).catch(() => undefined)
118 }
119 refresh()
120 refreshUnreadBadge()
121 // Joining is the whole point of accepting: land the user where they joined.
122 if (doing === 'accept') {
123 const target = notificationTarget(row)
124 if (target) navigate(target.href)
125 }
126 } catch (error) {
127 // 404 / 409 are not "try again": the code itself is spent, revoked or
128 // past its expiry. The row says so in its own words, so the banner would
129 // only repeat that in the server's English — keep it for real failures.
130 if (doing === 'accept' && error instanceof ApiFailure && (error.code === 404 || error.code === 409)) {
131 setInviteStates((current) => ({ ...current, [row.id]: 'void' }))
132 } else {
133 setFailed(errText(error))
134 }
135 } finally {
136 setPending(null)
137 }
138 }
139
140 return (
141 <section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
142 <div className="mx-auto w-full max-w-[720px]">
143 <SectionTitle title={t('notifications.title')} sub={t('notifications.sub')} />
144 <div className="mb-4 flex items-center gap-2">
145 <div className="settings-segment-group" role="group" aria-label={t('notifications.filter')}>
146 <button
147 type="button"
148 className="settings-segment"
149 aria-pressed={!onlyUnread}
150 onClick={() => setOnlyUnread(false)}
151 >
152 {t('common.state.all')}
153 </button>
154 <button
155 type="button"
156 className="settings-segment"
157 aria-pressed={onlyUnread}
158 onClick={() => setOnlyUnread(true)}
159 >
160 {t('notifications.unread')}
161 </button>
162 </div>
163 <button
164 type="button"
165 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"
166 onClick={() => void run(() => markNotificationsRead({ all: true }))}
167 >
168 <CheckCheck className="h-4 w-4" />
169 {t('notifications.markAllRead')}
170 </button>
171 </div>
172 {failed ? (
173 <div className="mb-3">
174 <Alert tone="error">{failed}</Alert>
175 </div>
176 ) : null}
177 {list.loading && rows.length === 0 ? (
178 <Spinner />
179 ) : rows.length === 0 ? (
180 <EmptyNote>{onlyUnread ? t('notifications.emptyUnread') : t('notifications.emptyAll')}</EmptyNote>
181 ) : (
182 <ul className="divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
183 {rows.map((row) => (
184 <NotificationRowView
185 key={row.id}
186 row={row}
187 inviteState={inviteStates[row.id]}
188 memberSpaceSlugs={memberSpaceSlugs}
189 pending={pending?.id === row.id ? pending.doing : null}
190 onOpen={() => open(row)}
191 onDecide={(kind, code, doing) => void decide(row, kind, code, doing)}
192 onDelete={() => void run(() => deleteNotification(row.id))}
193 />
194 ))}
195 </ul>
196 )}
197 </div>
198 </section>
199 )
200}
201
202function NotificationRowView({
203 row,
204 inviteState,
205 memberSpaceSlugs,
206 pending,
207 onOpen,
208 onDecide,
209 onDelete,
210}: {
211 row: NotificationRow
212 inviteState?: InviteState
213 memberSpaceSlugs?: Set<string>
214 pending: 'accept' | 'reject' | null
215 onOpen: () => void
216 onDecide: (kind: 'space' | 'room', code: string, doing: 'accept' | 'reject') => void
217 onDelete: () => void
218}) {
219 const { t } = useT()
220 const meta = NOTIFY_META[row.notification_type]
221 const unread = !row.read_at
222 const target = notificationTarget(row)
223 const invite = row.notification_type === 'invite'
224 // `null` means the row predates in-app accepting, or was mailed to an
225 // address rather than an account: there is nothing this page can send.
226 const decision = invite ? inviteDecision(row) : null
227 const payload = invite ? readPayload(row) : {}
228 const state: InviteState | undefined =
229 inviteState ??
230 (decision?.kind === 'space' && payload.spaceName && memberSpaceSlugs?.has(payload.spaceName) ? 'joined' : undefined)
231 // Being read is not being answered, so an invite stays actionable until it
232 // is actually settled — by this page, by membership, or by a spent code.
233 const actionable = Boolean(decision) && state === undefined
234 // An unaccepted channel is closed to you, so an invite row is not a link
235 // until you have joined it.
236 const link = invite ? (state === 'joined' ? target : null) : target
237
238 return (
239 <li>
240 <div
241 role={link ? 'button' : undefined}
242 tabIndex={link ? 0 : undefined}
243 onClick={link ? onOpen : undefined}
244 onKeyDown={
245 link
246 ? (event) => {
247 if (event.key === 'Enter' || event.key === ' ') {
248 event.preventDefault()
249 onOpen()
250 }
251 }
252 : undefined
253 }
254 className={`notify-row ${link ? 'is-link' : ''}`}
255 >
256 <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">
257 <meta.icon className="h-4 w-4" aria-hidden />
258 </span>
259 <span className="min-w-0 flex-1">
260 <span className="flex items-center gap-2">
261 <span className={`truncate text-[14px] ${unread ? 'font-semibold text-shell-ink' : 'text-shell-ink'}`}>
262 {row.title}
263 </span>
264 {unread ? (
265 <span
266 className="h-1.5 w-1.5 shrink-0 rounded-full bg-shell-ink-muted"
267 aria-label={t('notifications.unread')}
268 />
269 ) : null}
270 </span>
271 {row.body ? (
272 <span className="mt-0.5 block truncate text-[12.5px] text-shell-ink-muted">{row.body}</span>
273 ) : null}
274 <span className="mt-1 block text-[11px] text-shell-ink-muted">
275 {meta.label} · {fmtRelative(row.created_at)}
276 </span>
277 {invite && !decision ? (
278 <span className="notify-note mt-1.5 block">{t('notifications.inviteByEmail')}</span>
279 ) : invite && state === 'void' ? (
280 <span className="notify-note mt-1.5 block">{t('notifications.inviteVoid')}</span>
281 ) : null}
282 </span>
283 <span className="flex shrink-0 items-center gap-1.5">
284 {state === 'joined' ? (
285 <span className="notify-done">
286 <Check className="h-3.5 w-3.5" aria-hidden />
287 {t('notifications.joined')}
288 </span>
289 ) : actionable && decision ? (
290 <span className="notify-actions">
291 <button
292 type="button"
293 className="notify-button"
294 disabled={pending !== null}
295 onClick={(event) => {
296 event.stopPropagation()
297 onDecide(decision.kind, decision.code, 'reject')
298 }}
299 >
300 {pending === 'reject' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
301 {t('notifications.reject')}
302 </button>
303 <button
304 type="button"
305 className="notify-button is-primary"
306 disabled={pending !== null}
307 onClick={(event) => {
308 event.stopPropagation()
309 onDecide(decision.kind, decision.code, 'accept')
310 }}
311 >
312 {pending === 'accept' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
313 {t('notifications.accept')}
314 </button>
315 </span>
316 ) : link ? (
317 <ArrowUpRight className="notify-jump h-4 w-4" aria-hidden />
318 ) : null}
319 <button
320 type="button"
321 aria-label={t('notifications.delete')}
322 title={t('notifications.delete')}
323 className="workspace-icon-button -mr-1 h-8 w-8"
324 onClick={(event) => {
325 event.stopPropagation()
326 onDelete()
327 }}
328 >
329 <Trash2 className="h-3.5 w-3.5" />
330 </button>
331 </span>
332 </div>
333 </li>
334 )
335}
Added view/app/src/domains/notifications/index.ts view/app/src/domains/notifications/index.ts +9 −0 View view/app/src/domains/notifications/index.ts
@@ -0,0 +1,9 @@
1// Public API of features/notifications — the personal inbox at
2// `/app/@me/notify`, the realtime bridge that turns WS `notification` user
3// events into toasts or in-place refreshes, and the sidebar / footer entries.
4// Consumers import from '@app/domains/notifications', never deeper files.
5
6export type { NotificationRow } from './model/api'
7export { NOTIFY_PATH, NotifyNavItem } from './ui/notify-nav-item'
8export { NotifyToaster } from './ui/notify-toaster'
9export { NotifyView } from './ui/notify-view'