From 87975175500eb7921096821c2494aef8763b0215 Mon Sep 17 00:00:00 2001 From: soufian3hm Date: Wed, 15 Jul 2026 01:07:44 +0100 Subject: [PATCH 1/2] feat(react): notification action buttons Frontend slice. WellKnownPayload gains primary_action/secondary_action ({ label, url? }, exported as PayloadAction). The default item renders them as buttons below the content, with onPrimaryActionClick/ onSecondaryActionClick props and a renderActions escape hatch. URLs reuse a shared safe-navigation helper (followActionUrl), and the item divider moves to the row so the buttons sit inside it. Closes #35 --- .changeset/notification-actions.md | 6 ++ packages/client/src/index.ts | 1 + packages/client/src/types.ts | 13 +++ packages/react/src/Inbox.tsx | 3 + .../react/src/components/InboxContent.tsx | 42 +++++++--- .../react/src/components/NotificationList.tsx | 70 +++++++++++++++- packages/react/src/inbox.test.tsx | 81 +++++++++++++++++++ packages/react/src/navigation.ts | 18 +++++ packages/react/src/styles.ts | 36 ++++++++- 9 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 .changeset/notification-actions.md diff --git a/.changeset/notification-actions.md b/.changeset/notification-actions.md new file mode 100644 index 0000000..d270ed8 --- /dev/null +++ b/.changeset/notification-actions.md @@ -0,0 +1,6 @@ +--- +"@chimely/client": minor +"@chimely/react": minor +--- + +Notification action buttons (frontend slice). `WellKnownPayload` gains optional `primary_action` / `secondary_action` (`{ label, url? }`, exported as `PayloadAction`). The default item renders them as buttons below the content, with new `onPrimaryActionClick` / `onSecondaryActionClick` props and a `renderActions` escape hatch. Action URLs follow the same safe-navigation path as `action_url` (same-origin via `routerPush`, `javascript:`/`data:` refused). No server change: payloads pass through verbatim. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2fd67c1..7e1c949 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -22,6 +22,7 @@ export type { InboxItemSource, InboxSnapshot, NotificationId, + PayloadAction, Preference, WellKnownPayload, } from './types'; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 31d1c3f..63ece32 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -8,6 +8,15 @@ import type { ChimelyError } from './errors'; export type InboxItemSource = 'notification' | 'broadcast'; +/** + * A call-to-action button the default item renders. The optional `url` is + * followed on click through the same safe-navigation path as `action_url`. + */ +export interface PayloadAction { + label: string; + url?: string; +} + /** TypeID of a direct notification: `notif_` + UUIDv7 in Crockford base32. */ export type NotificationId = `notif_${string}`; /** TypeID of a broadcast: `bcast_` + UUIDv7 in Crockford base32. */ @@ -30,6 +39,10 @@ export interface WellKnownPayload { action_url?: string; /** Leading icon/avatar in the default rendering. */ icon_url?: string; + /** Primary call-to-action button on the default item. */ + primary_action?: PayloadAction; + /** Secondary call-to-action, rendered beside the primary. */ + secondary_action?: PayloadAction; [custom: string]: unknown; } diff --git a/packages/react/src/Inbox.tsx b/packages/react/src/Inbox.tsx index 8b7dabf..0812ad8 100644 --- a/packages/react/src/Inbox.tsx +++ b/packages/react/src/Inbox.tsx @@ -214,10 +214,13 @@ function InboxView(props: InboxProps): ReactNode { titleId={titleId} preferencesPanel={props.preferencesPanel} onItemClick={props.onItemClick} + onPrimaryActionClick={props.onPrimaryActionClick} + onSecondaryActionClick={props.onSecondaryActionClick} routerPush={props.routerPush} renderItem={props.renderItem} renderEmpty={props.renderEmpty} renderFooter={props.renderFooter} + renderActions={props.renderActions} renderSubject={props.renderSubject} renderBody={props.renderBody} renderAvatar={props.renderAvatar} diff --git a/packages/react/src/components/InboxContent.tsx b/packages/react/src/components/InboxContent.tsx index 4c5f1c6..0f31969 100644 --- a/packages/react/src/components/InboxContent.tsx +++ b/packages/react/src/components/InboxContent.tsx @@ -6,7 +6,7 @@ import { slotClass, slotStyle, variablesToStyle } from '../appearance'; import { useNotifications } from '../hooks'; import type { InboxLocalization } from '../localization'; import { mergeLocalization } from '../localization'; -import { navigation, resolveActionUrl } from '../navigation'; +import { followActionUrl } from '../navigation'; import { ensureStyles } from '../styles'; import type { ItemRenderProps } from './DefaultItem'; import { GearIcon } from './icons'; @@ -56,6 +56,17 @@ export interface InboxContentProps extends ItemRend renderItem?: (ctx: { item: InboxItem; markRead: () => Promise }) => ReactNode; renderEmpty?: () => ReactNode; renderFooter?: () => ReactNode; + /** + * Primary action-button click. Default (follow `primary_action.url` through + * routerPush/full navigation) runs unless this returns false. + */ + // biome-ignore lint/suspicious/noConfusingVoidType: frozen contract type + onPrimaryActionClick?: (item: InboxItem) => boolean | void; + /** Secondary action-button click. Symmetric with onPrimaryActionClick. */ + // biome-ignore lint/suspicious/noConfusingVoidType: frozen contract type + onSecondaryActionClick?: (item: InboxItem) => boolean | void; + /** Replace the default action buttons with custom UI. */ + renderActions?: (ctx: { item: InboxItem }) => ReactNode; } export function InboxContent( @@ -184,21 +195,26 @@ export function InboxContent( } void markRead({ id: item.id, source: item.source }).then(() => { const url = (item.payload as Partial).action_url; - if (typeof url !== 'string' || url.length === 0) { - return; - } - const resolved = resolveActionUrl(url); - if (!resolved) { - return; - } - if (resolved.kind === 'same-origin' && props.routerPush) { - props.routerPush(resolved.path); - } else { - navigation.assign(url); + if (typeof url === 'string' && url.length > 0) { + followActionUrl(url, props.routerPush); } }); }; + // Action buttons do not mark the item read: the CTA is a distinct intent + // from opening the notification. The optional callback can veto navigation. + const handleActionClick = (item: InboxItem, which: 'primary' | 'secondary') => { + const onClick = which === 'primary' ? props.onPrimaryActionClick : props.onSecondaryActionClick; + if (onClick?.(item) === false) { + return; + } + const payload = item.payload as Partial; + const action = which === 'primary' ? payload.primary_action : payload.secondary_action; + if (action && typeof action.url === 'string' && action.url.length > 0) { + followActionUrl(action.url, props.routerPush); + } + }; + return (
@@ -349,6 +365,7 @@ export function InboxContent( archive={archive} unarchive={unarchive} onItem={handleItemClick} + onAction={handleActionClick} cls={cls} strings={strings} appearance={props.appearance} @@ -356,6 +373,7 @@ export function InboxContent( deferEmpty={activeFilter !== undefined && hasMore} renderItem={props.renderItem} renderEmpty={props.renderEmpty} + renderActions={props.renderActions} renderSubject={props.renderSubject} renderBody={props.renderBody} renderAvatar={props.renderAvatar} diff --git a/packages/react/src/components/NotificationList.tsx b/packages/react/src/components/NotificationList.tsx index 3d29bbf..cfc1083 100644 --- a/packages/react/src/components/NotificationList.tsx +++ b/packages/react/src/components/NotificationList.tsx @@ -1,4 +1,10 @@ -import type { ChimelyError, InboxItem, InboxItemId, InboxItemSource } from '@chimely/client'; +import type { + ChimelyError, + InboxItem, + InboxItemId, + InboxItemSource, + WellKnownPayload, +} from '@chimely/client'; import type { ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { InboxAppearance, InboxSlot } from '../appearance'; @@ -21,6 +27,7 @@ interface NotificationListProps extends ItemRenderProps { archive: (item: { id: InboxItemId; source: InboxItemSource }) => Promise; unarchive: (item: { id: InboxItemId; source: InboxItemSource }) => Promise; onItem: (item: InboxItem) => void; + onAction: (item: InboxItem, which: 'primary' | 'secondary') => void; cls: (slot: InboxSlot) => string; strings: Required; appearance?: InboxAppearance; @@ -28,6 +35,8 @@ interface NotificationListProps extends ItemRenderProps { newItemIds?: ReadonlyArray; renderItem?: (ctx: { item: InboxItem; markRead: () => Promise }) => ReactNode; renderEmpty?: () => ReactNode; + /** Replace the default per-item action buttons with custom UI. */ + renderActions?: (ctx: { item: InboxItem }) => ReactNode; /** * Suppress the empty state while more pages may still fill the view. * Set when a tab filter is active and hasMore is true. @@ -276,6 +285,11 @@ export function NotificationList(props: NotificationListProps + )} @@ -285,3 +299,57 @@ export function NotificationList(props: NotificationListProps ); } + +/** A non-empty string label off a payload action, or null. Payloads are verbatim. */ +function actionLabel(action: unknown): string | null { + if (action !== null && typeof action === 'object' && 'label' in action) { + const { label } = action as { label: unknown }; + if (typeof label === 'string' && label.length > 0) { + return label; + } + } + return null; +} + +/** + * The default item's call-to-action buttons, a sibling of the item button (a + * button cannot nest inside a button). renderActions replaces them wholesale. + * With neither actions nor renderActions, nothing renders. + */ +function ItemActions(props: { + item: InboxItem; + onAction: (item: InboxItem, which: 'primary' | 'secondary') => void; + renderActions?: (ctx: { item: InboxItem }) => ReactNode; +}): ReactNode { + if (props.renderActions) { + return
{props.renderActions({ item: props.item })}
; + } + const payload = props.item.payload as Partial; + const primary = actionLabel(payload.primary_action); + const secondary = actionLabel(payload.secondary_action); + if (primary === null && secondary === null) { + return null; + } + return ( +
+ {primary !== null && ( + + )} + {secondary !== null && ( + + )} +
+ ); +} diff --git a/packages/react/src/inbox.test.tsx b/packages/react/src/inbox.test.tsx index fb8aec4..51725ef 100644 --- a/packages/react/src/inbox.test.tsx +++ b/packages/react/src/inbox.test.tsx @@ -848,3 +848,84 @@ describe('standalone mode', () => { expect(stub.stream().closed).toBe(true); }); }); + +describe('action buttons', () => { + test('renders primary and secondary buttons and follows the action url', async () => { + const assign = vi.spyOn(navigation, 'assign').mockImplementation(() => {}); + const stub = createStubServer(); + stub.addNotification({ + payload: { + title: 'invoice', + primary_action: { label: 'View invoice', url: 'https://app.test/invoices/42' }, + secondary_action: { label: 'Dismiss' }, + }, + }); + await renderInbox(stub); + fireEvent.click(bell()); + + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'View invoice' })); + expect(assign).toHaveBeenCalledWith('https://app.test/invoices/42'); + }); + + test('renders no action bar without a primary or secondary action', async () => { + const stub = createStubServer(); + stub.addNotification({ payload: { title: 'plain' } }); + await renderInbox(stub); + fireEvent.click(bell()); + expect(document.querySelector('.chimely-item-cta')).toBeNull(); + }); + + test('same-origin action urls go through routerPush', async () => { + const assign = vi.spyOn(navigation, 'assign').mockImplementation(() => {}); + const routerPush = vi.fn(); + const stub = createStubServer(); + stub.addNotification({ + payload: { title: 'spa', primary_action: { label: 'Open', url: '/settings?tab=1' } }, + }); + await renderInbox(stub, { routerPush }); + fireEvent.click(bell()); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(routerPush).toHaveBeenCalledWith('/settings?tab=1'); + expect(assign).not.toHaveBeenCalled(); + }); + + test('onPrimaryActionClick returning false suppresses navigation', async () => { + const assign = vi.spyOn(navigation, 'assign').mockImplementation(() => {}); + const stub = createStubServer(); + stub.addNotification({ + payload: { title: 'x', primary_action: { label: 'Go', url: 'https://app.test/go' } }, + }); + const onPrimaryActionClick = vi.fn(() => false); + await renderInbox(stub, { onPrimaryActionClick }); + fireEvent.click(bell()); + fireEvent.click(screen.getByRole('button', { name: 'Go' })); + expect(onPrimaryActionClick).toHaveBeenCalledTimes(1); + expect(assign).not.toHaveBeenCalled(); + }); + + test('a javascript: action url on a button never navigates', async () => { + const assign = vi.spyOn(navigation, 'assign').mockImplementation(() => {}); + const stub = createStubServer(); + stub.addNotification({ + payload: { title: 'x', primary_action: { label: 'Hostile', url: 'javascript:alert(1)' } }, + }); + await renderInbox(stub); + fireEvent.click(bell()); + fireEvent.click(screen.getByRole('button', { name: 'Hostile' })); + expect(assign).not.toHaveBeenCalled(); + }); + + test('renderActions replaces the default buttons', async () => { + const stub = createStubServer(); + stub.addNotification({ + payload: { title: 'x', primary_action: { label: 'Default', url: 'https://app.test' } }, + }); + await renderInbox(stub, { + renderActions: ({ item }) => , + }); + fireEvent.click(bell()); + expect(screen.queryByRole('button', { name: 'Default' })).toBeNull(); + expect(screen.getByRole('button', { name: /Custom/ })).toBeDefined(); + }); +}); diff --git a/packages/react/src/navigation.ts b/packages/react/src/navigation.ts index ffd6a0c..8324b08 100644 --- a/packages/react/src/navigation.ts +++ b/packages/react/src/navigation.ts @@ -53,3 +53,21 @@ export function resolveActionUrl(url: string): ResolvedActionUrl | null { } return { kind: 'external', href: url }; } + +/** + * Follow a customer-supplied action URL: same-origin targets go through the + * SPA `routerPush` when one is provided, everything else (and any router-less + * case) falls back to a full assign. Unsafe URLs (per resolveActionUrl) are + * ignored. Shared by the item click and the action buttons. + */ +export function followActionUrl(url: string, routerPush?: (path: string) => void): void { + const resolved = resolveActionUrl(url); + if (!resolved) { + return; + } + if (resolved.kind === 'same-origin' && routerPush) { + routerPush(resolved.path); + } else { + navigation.assign(url); + } +} diff --git a/packages/react/src/styles.ts b/packages/react/src/styles.ts index ebbc3a1..af97379 100644 --- a/packages/react/src/styles.ts +++ b/packages/react/src/styles.ts @@ -142,7 +142,9 @@ export const INBOX_CSS = ` } .chimely-bell:focus-visible, .chimely-header-action:focus-visible, -.chimely-item:focus-visible { +.chimely-item:focus-visible, +.chimely-item-cta-primary:focus-visible, +.chimely-item-cta-secondary:focus-visible { outline: 2px solid var(--chimely-colorPrimary, #1264FF); outline-offset: 2px; } @@ -223,6 +225,11 @@ export const INBOX_CSS = ` .chimely-list-row { position: relative; } +/* The row owns the divider so action buttons sit inside it, above the line. + Scoped to default-item rows: custom renderItem rows keep their own layout. */ +.chimely-list-row:has(> .chimely-item) { + border-bottom: 1px solid var(--chimely-colorMuted, #f3f4f6); +} .chimely-item-actions { position: absolute; top: 8px; @@ -259,13 +266,38 @@ export const INBOX_CSS = ` width: 100%; padding: 12px 14px; border: none; - border-bottom: 1px solid var(--chimely-colorMuted, #f3f4f6); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.chimely-item-cta { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 14px 12px; +} +.chimely-item-cta-primary, +.chimely-item-cta-secondary { + border: 1px solid var(--chimely-colorMuted, #e5e7eb); + border-radius: var(--chimely-borderRadius, 8px); + background: transparent; + color: inherit; + font: inherit; + font-size: 0.9em; + font-weight: 500; + padding: 5px 12px; + cursor: pointer; +} +.chimely-item-cta-primary { + background: var(--chimely-colorPrimary, #1264FF); + border-color: var(--chimely-colorPrimary, #1264FF); + color: var(--chimely-colorBadgeForeground, #ffffff); +} +.chimely-item-cta-secondary:hover { + background: var(--chimely-colorMuted, #f3f4f6); +} .chimely-item:hover { background: var(--chimely-colorMuted, #f9fafb); } From 1f45a2113088f1d2c841e239b97cae45f4952317 Mon Sep 17 00:00:00 2001 From: soufian3hm Date: Wed, 15 Jul 2026 01:36:25 +0100 Subject: [PATCH 2/2] fix(react): reject whitespace-only action labels --- packages/react/src/components/NotificationList.tsx | 7 +++++-- packages/react/src/inbox.test.tsx | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/NotificationList.tsx b/packages/react/src/components/NotificationList.tsx index cfc1083..cc34bf8 100644 --- a/packages/react/src/components/NotificationList.tsx +++ b/packages/react/src/components/NotificationList.tsx @@ -300,11 +300,14 @@ export function NotificationList(props: NotificationListProps 0) { + if (typeof label === 'string' && label.trim().length > 0) { return label; } } diff --git a/packages/react/src/inbox.test.tsx b/packages/react/src/inbox.test.tsx index 51725ef..709eab5 100644 --- a/packages/react/src/inbox.test.tsx +++ b/packages/react/src/inbox.test.tsx @@ -876,6 +876,16 @@ describe('action buttons', () => { expect(document.querySelector('.chimely-item-cta')).toBeNull(); }); + test('a whitespace-only label renders no button', async () => { + const stub = createStubServer(); + stub.addNotification({ + payload: { title: 'x', primary_action: { label: ' ', url: 'https://app.test/x' } }, + }); + await renderInbox(stub); + fireEvent.click(bell()); + expect(document.querySelector('.chimely-item-cta')).toBeNull(); + }); + test('same-origin action urls go through routerPush', async () => { const assign = vi.spyOn(navigation, 'assign').mockImplementation(() => {}); const routerPush = vi.fn();