Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/notification-actions.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type {
InboxItemSource,
InboxSnapshot,
NotificationId,
PayloadAction,
Preference,
WellKnownPayload,
} from './types';
13 changes: 13 additions & 0 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,13 @@ function InboxView<TPayload>(props: InboxProps<TPayload>): 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}
Expand Down
42 changes: 30 additions & 12 deletions packages/react/src/components/InboxContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +56,17 @@ export interface InboxContentProps<TPayload = WellKnownPayload> extends ItemRend
renderItem?: (ctx: { item: InboxItem<TPayload>; markRead: () => Promise<void> }) => 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<TPayload>) => boolean | void;
/** Secondary action-button click. Symmetric with onPrimaryActionClick. */
// biome-ignore lint/suspicious/noConfusingVoidType: frozen contract type
onSecondaryActionClick?: (item: InboxItem<TPayload>) => boolean | void;
/** Replace the default action buttons with custom UI. */
renderActions?: (ctx: { item: InboxItem<TPayload> }) => ReactNode;
}

export function InboxContent<TPayload = WellKnownPayload>(
Expand Down Expand Up @@ -184,21 +195,26 @@ export function InboxContent<TPayload = WellKnownPayload>(
}
void markRead({ id: item.id, source: item.source }).then(() => {
const url = (item.payload as Partial<WellKnownPayload>).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<TPayload>, which: 'primary' | 'secondary') => {
const onClick = which === 'primary' ? props.onPrimaryActionClick : props.onSecondaryActionClick;
if (onClick?.(item) === false) {
return;
}
const payload = item.payload as Partial<WellKnownPayload>;
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 (
<div className={cls('content')} style={style}>
<div className={cls('header')} style={slotStyle(props.appearance, 'header')}>
Expand Down Expand Up @@ -349,13 +365,15 @@ export function InboxContent<TPayload = WellKnownPayload>(
archive={archive}
unarchive={unarchive}
onItem={handleItemClick}
onAction={handleActionClick}
cls={cls}
strings={strings}
appearance={props.appearance}
newItemIds={visibleNewItemIds}
deferEmpty={activeFilter !== undefined && hasMore}
renderItem={props.renderItem}
renderEmpty={props.renderEmpty}
renderActions={props.renderActions}
renderSubject={props.renderSubject}
renderBody={props.renderBody}
renderAvatar={props.renderAvatar}
Expand Down
73 changes: 72 additions & 1 deletion packages/react/src/components/NotificationList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,13 +27,16 @@ interface NotificationListProps<TPayload> extends ItemRenderProps<TPayload> {
archive: (item: { id: InboxItemId; source: InboxItemSource }) => Promise<void>;
unarchive: (item: { id: InboxItemId; source: InboxItemSource }) => Promise<void>;
onItem: (item: InboxItem<TPayload>) => void;
onAction: (item: InboxItem<TPayload>, which: 'primary' | 'secondary') => void;
cls: (slot: InboxSlot) => string;
strings: Required<InboxLocalization>;
appearance?: InboxAppearance;
/** Ids the last refresh merged in, for the new-notification pill. */
newItemIds?: ReadonlyArray<InboxItemId>;
renderItem?: (ctx: { item: InboxItem<TPayload>; markRead: () => Promise<void> }) => ReactNode;
renderEmpty?: () => ReactNode;
/** Replace the default per-item action buttons with custom UI. */
renderActions?: (ctx: { item: InboxItem<TPayload> }) => ReactNode;
/**
* Suppress the empty state while more pages may still fill the view.
* Set when a tab filter is active and hasMore is true.
Expand Down Expand Up @@ -276,6 +285,11 @@ export function NotificationList<TPayload>(props: NotificationListProps<TPayload
{item.archived === true ? '\u21a5' : '\u21a7'}
</button>
</span>
<ItemActions
item={item}
onAction={props.onAction}
renderActions={props.renderActions}
/>
</>
)}
</li>
Expand All @@ -285,3 +299,60 @@ export function NotificationList<TPayload>(props: NotificationListProps<TPayload
</div>
);
}

/**
* A non-blank string label off a payload action, or null. Payloads are
* verbatim, so a whitespace-only label must not render an invisible button.
*/
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.trim().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<TPayload>(props: {
item: InboxItem<TPayload>;
onAction: (item: InboxItem<TPayload>, which: 'primary' | 'secondary') => void;
renderActions?: (ctx: { item: InboxItem<TPayload> }) => ReactNode;
}): ReactNode {
if (props.renderActions) {
return <div className="chimely-item-cta">{props.renderActions({ item: props.item })}</div>;
}
const payload = props.item.payload as Partial<WellKnownPayload>;
const primary = actionLabel(payload.primary_action);
const secondary = actionLabel(payload.secondary_action);
if (primary === null && secondary === null) {
return null;
}
return (
<div className="chimely-item-cta">
{primary !== null && (
<button
type="button"
className="chimely-item-cta-primary"
onClick={() => props.onAction(props.item, 'primary')}
>
{primary}
</button>
)}
{secondary !== null && (
<button
type="button"
className="chimely-item-cta-secondary"
onClick={() => props.onAction(props.item, 'secondary')}
>
{secondary}
</button>
)}
</div>
);
}
91 changes: 91 additions & 0 deletions packages/react/src/inbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -848,3 +848,94 @@ 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('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();
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 }) => <button type="button">Custom {item.category}</button>,
});
fireEvent.click(bell());
expect(screen.queryByRole('button', { name: 'Default' })).toBeNull();
expect(screen.getByRole('button', { name: /Custom/ })).toBeDefined();
});
});
18 changes: 18 additions & 0 deletions packages/react/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading