From 2bdd97692bb15e7c7d79931db7dd4e06776dfbec Mon Sep 17 00:00:00 2001 From: Steven Lindsay Date: Fri, 27 Mar 2026 15:22:44 +0000 Subject: [PATCH 1/2] Add React hooks for managing Ably Push Notifications Introduced `usePush` and `usePushActivation` hooks to simplify activation and subscription management for push notifications in React. Includes detailed documentation, tests, and API reference to guide integration and error handling. --- docs/react-push.md | 371 ++++++++++++++++++ src/platform/react-hooks/src/fakes/ably.ts | 24 ++ .../react-hooks/src/hooks/usePush.test.tsx | 142 +++++++ src/platform/react-hooks/src/hooks/usePush.ts | 50 +++ .../src/hooks/usePushActivation.test.tsx | 75 ++++ .../src/hooks/usePushActivation.ts | 17 + src/platform/react-hooks/src/index.ts | 2 + 7 files changed, 681 insertions(+) create mode 100644 docs/react-push.md create mode 100644 src/platform/react-hooks/src/hooks/usePush.test.tsx create mode 100644 src/platform/react-hooks/src/hooks/usePush.ts create mode 100644 src/platform/react-hooks/src/hooks/usePushActivation.test.tsx create mode 100644 src/platform/react-hooks/src/hooks/usePushActivation.ts diff --git a/docs/react-push.md b/docs/react-push.md new file mode 100644 index 0000000000..a1b0c0ea9a --- /dev/null +++ b/docs/react-push.md @@ -0,0 +1,371 @@ +# React Hooks for Push Notifications + +Use Ably Push Notifications in your React application using idiomatic React Hooks. + +Using these hooks you can: + +- [Activate and deactivate devices](https://ably.com/docs/push/activate-subscribe) for push notifications +- [Subscribe devices or clients](https://ably.com/docs/push/activate-subscribe#subscribing) to push notifications on channels +- List active push subscriptions for a channel + +> [!NOTE] +> Push notifications require the Push plugin to be loaded. If you're using the modular bundle, ensure the Push plugin is included in your client options. See the [Push Notifications documentation](https://ably.com/docs/push) for general concepts and setup. + +--- + + + + +- [Prerequisites](#prerequisites) +- [usePushActivation](#usepushactivation) +- [usePush](#usepush) +- [Error Handling](#error-handling) +- [Full Example](#full-example) +- [API Reference](#api-reference) + +## + +## Prerequisites + +Push hooks require the Ably client to be configured with the Push plugin. When using the default `ably` bundle, the Push plugin is included automatically. If you're using the modular bundle, you must provide it explicitly: + +```jsx +import * as Ably from 'ably'; +import Push from 'ably/push'; + +const client = new Ably.Realtime({ + key: 'your-ably-api-key', + clientId: 'me', + plugins: { Push }, +}); + +root.render( + + + , +); +``` + +--- + +## usePushActivation + +The `usePushActivation` hook provides functions to activate and deactivate the current device for push notifications. It works directly under an `AblyProvider` and does **not** require a `ChannelProvider`. + +```jsx +import { usePushActivation } from 'ably/react'; + +const PushActivationComponent = () => { + const { activate, deactivate } = usePushActivation(); + + return ( +
+ + +
+ ); +}; +``` + +#### Activation lifecycle + +Activation registers the device with Ably's push service (on web, this requests browser notification permission and registers a service worker). The device identity is persisted to `localStorage`, so: + +- **Activation survives page reloads and app restarts.** You do not need to call `activate()` on every mount. +- **Calling `activate()` when already activated is safe** — it confirms the existing registration without side effects. +- **`deactivate()` is for explicit user opt-out only.** It removes the device registration from Ably's servers and clears all persisted push state. Do not call it on unmount or app close. + +A typical pattern is to call `activate()` once in response to a user action (e.g. tapping "Enable notifications"), not automatically on mount: + +```jsx +const NotificationBanner = () => { + const { activate } = usePushActivation(); + const [enabled, setEnabled] = useState(false); + + const handleEnable = async () => { + try { + await activate(); + setEnabled(true); + } catch (err) { + console.error('Push activation failed:', err); + } + }; + + if (enabled) return null; + + return ( +
+

Get notified about new updates

+ +
+ ); +}; +``` + +#### Multiple clients + +If you use multiple Ably clients via the `ablyId` pattern, pass the ID to `usePushActivation`: + +```jsx +const { activate, deactivate } = usePushActivation('providerOne'); +``` + +--- + +## usePush + +The `usePush` hook provides functions to manage push notification subscriptions for a specific channel. It must be used inside a `ChannelProvider`. + +```jsx +import { usePush } from 'ably/react'; + +const PushSubscriptionComponent = () => { + const { subscribeDevice, unsubscribeDevice } = usePush('your-channel-name'); + + return ( +
+ + +
+ ); +}; +``` + +> [!IMPORTANT] +> The device must be activated (via `usePushActivation`) before calling `subscribeDevice()` or `unsubscribeDevice()`. See [Error Handling](#error-handling) for details on what happens if activation hasn't been completed. + +#### Subscribe by device or by client + +`usePush` supports both device-level and client-level subscriptions: + +```jsx +const { + subscribeDevice, // Subscribe the current device + unsubscribeDevice, // Unsubscribe the current device + subscribeClient, // Subscribe all devices for the current clientId + unsubscribeClient, // Unsubscribe all devices for the current clientId +} = usePush('your-channel-name'); +``` + +- **Device subscriptions** target the specific device. Use when you want per-device control. +- **Client subscriptions** target all devices that share the same `clientId`. Use when a user should receive push notifications regardless of which device they're on. + +> [!NOTE] +> `subscribeClient` and `unsubscribeClient` require the Ably client to be configured with a `clientId`. An error will be thrown if no `clientId` is set. + +#### Listing subscriptions + +You can list active push subscriptions for the channel: + +```jsx +const { listSubscriptions } = usePush('your-channel-name'); + +const handleListSubscriptions = async () => { + const result = await listSubscriptions(); + console.log('Active subscriptions:', result.items); +}; +``` + +`listSubscriptions` accepts an optional params object to filter by `deviceId` or `clientId`: + +```jsx +const result = await listSubscriptions({ deviceId: 'specific-device-id' }); +``` + +#### Push subscriptions are persistent + +Unlike presence (which enters on mount and leaves on unmount), push subscriptions are **persistent server-side state**. They survive app restarts and are not automatically removed when a component unmounts. This is by design — push notifications are meant to be delivered even when your app is not running. + +To remove a subscription, explicitly call `unsubscribeDevice()` or `unsubscribeClient()` in response to a user action. + +--- + +## Error Handling + +### Push plugin not loaded + +If the Push plugin is not included in your client configuration, `usePush` will throw immediately on render: + +``` +Error: Push plugin not provided (code: 40019) +``` + +For `usePushActivation`, the error is thrown when `activate()` or `deactivate()` is called. + +To fix this, ensure the Push plugin is loaded. See [Prerequisites](#prerequisites). + +### Device not activated + +If you call `subscribeDevice()` or `unsubscribeDevice()` before the device has been activated, the promise will reject with: + +``` +Error: Cannot subscribe from client without deviceIdentityToken (code: 50000) +``` + +Ensure `activate()` has completed successfully before subscribing: + +```jsx +const { activate } = usePushActivation(); +const { subscribeDevice } = usePush('alerts'); + +const handleEnablePush = async () => { + await activate(); + await subscribeDevice(); +}; +``` + +### No clientId set + +If you call `subscribeClient()` or `unsubscribeClient()` without a `clientId` configured on the Ably client, the promise will reject with: + +``` +Error: Cannot subscribe from client without client ID (code: 50000) +``` + +Ensure your Ably client is created with a `clientId`: + +```jsx +const client = new Ably.Realtime({ key: 'your-api-key', clientId: 'me' }); +``` + +### Connection and channel errors + +Like other channel-level hooks, `usePush` returns `connectionError` and `channelError`: + +```jsx +const { subscribeDevice, connectionError, channelError } = usePush('your-channel-name'); + +if (connectionError) { + return

Connection error: {connectionError.message}

; +} +if (channelError) { + return

Channel error: {channelError.message}

; +} +``` + +--- + +## Full Example + +A complete example showing activation, channel subscription, and error handling: + +```jsx +import { AblyProvider, ChannelProvider, usePushActivation, usePush } from 'ably/react'; +import * as Ably from 'ably'; +import { useState } from 'react'; + +const client = new Ably.Realtime({ key: 'your-ably-api-key', clientId: 'me' }); + +const App = () => ( + + + + + + +); + +const PushActivation = () => { + const { activate, deactivate } = usePushActivation(); + const [active, setActive] = useState(false); + const [error, setError] = useState(null); + + const handleToggle = async () => { + try { + if (active) { + await deactivate(); + } else { + await activate(); + } + setActive(!active); + setError(null); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+ + {error &&

{error}

} +
+ ); +}; + +const AlertSubscription = () => { + const { subscribeDevice, unsubscribeDevice, connectionError, channelError } = usePush('alerts'); + const [subscribed, setSubscribed] = useState(false); + + if (connectionError) return

Connection error: {connectionError.message}

; + if (channelError) return

Channel error: {channelError.message}

; + + const handleToggle = async () => { + try { + if (subscribed) { + await unsubscribeDevice(); + } else { + await subscribeDevice(); + } + setSubscribed(!subscribed); + } catch (err) { + console.error('Subscription error:', err); + } + }; + + return ( + + ); +}; +``` + +--- + +## API Reference + +### `usePushActivation` + +```typescript +function usePushActivation(ablyId?: string): PushActivationResult; + +interface PushActivationResult { + activate: () => Promise; + deactivate: () => Promise; +} +``` + +| Property | Type | Description | +| ------------ | --------------------- | --------------------------------------------------------------------------- | +| `activate` | `() => Promise` | Activates the device for push notifications. Persists to `localStorage`. | +| `deactivate` | `() => Promise` | Deactivates the device and removes the registration from Ably's servers. | + +### `usePush` + +```typescript +function usePush(channelNameOrNameAndOptions: ChannelParameters): PushResult; + +interface PushResult { + channel: Ably.RealtimeChannel; + subscribeDevice: () => Promise; + unsubscribeDevice: () => Promise; + subscribeClient: () => Promise; + unsubscribeClient: () => Promise; + listSubscriptions: (params?: Record) => Promise>; + connectionError: Ably.ErrorInfo | null; + channelError: Ably.ErrorInfo | null; +} +``` + +| Property | Type | Description | +| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------ | +| `channel` | `Ably.RealtimeChannel` | The channel instance. | +| `subscribeDevice` | `() => Promise` | Subscribes the current device to push notifications on this channel. | +| `unsubscribeDevice` | `() => Promise` | Unsubscribes the current device from push notifications on this channel. | +| `subscribeClient` | `() => Promise` | Subscribes all devices for the current `clientId` to push on this channel. | +| `unsubscribeClient` | `() => Promise` | Unsubscribes all devices for the current `clientId` from push on this channel. | +| `listSubscriptions` | `(params?) => Promise>` | Lists active push subscriptions for this channel. | +| `connectionError` | `Ably.ErrorInfo \| null` | Current connection error, if any. | +| `channelError` | `Ably.ErrorInfo \| null` | Current channel error, if any. | diff --git a/src/platform/react-hooks/src/fakes/ably.ts b/src/platform/react-hooks/src/fakes/ably.ts index 32fe8d4056..3906ffd56e 100644 --- a/src/platform/react-hooks/src/fakes/ably.ts +++ b/src/platform/react-hooks/src/fakes/ably.ts @@ -126,6 +126,7 @@ export class ClientSingleChannelConnection extends EventEmitter { private channel: Channel; public presence: any; + public push: any; public state: string; public name: string; @@ -134,6 +135,7 @@ export class ClientSingleChannelConnection extends EventEmitter { this.client = client; this.channel = channel; this.presence = new ClientPresenceConnection(this.client, this.channel.presence); + this.push = new ClientPushConnection(); this.state = 'attached'; this.name = name; } @@ -446,3 +448,25 @@ export class ChannelPresence { } } } + +export class ClientPushConnection { + public async subscribeDevice() { + // do nothing + } + + public async unsubscribeDevice() { + // do nothing + } + + public async subscribeClient() { + // do nothing + } + + public async unsubscribeClient() { + // do nothing + } + + public async listSubscriptions(_params?: Record) { + return { items: [], hasNext: () => false, isLast: () => true } as any; + } +} diff --git a/src/platform/react-hooks/src/hooks/usePush.test.tsx b/src/platform/react-hooks/src/hooks/usePush.test.tsx new file mode 100644 index 0000000000..6d467943e8 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePush.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import type * as Ably from 'ably'; +import { it, beforeEach, describe, expect, vi } from 'vitest'; +import { usePush } from './usePush.js'; +import { renderHook, act } from '@testing-library/react'; +import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably.js'; +import { AblyProvider } from '../AblyProvider.js'; +import { ChannelProvider } from '../ChannelProvider.js'; + +const testChannelName = 'testChannel'; + +function renderInCtxProvider(client: FakeAblySdk) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + return renderHook(() => usePush({ channelName: testChannelName }), { wrapper }); +} + +describe('usePush', () => { + let channels: FakeAblyChannels; + let ablyClient: FakeAblySdk; + + beforeEach(() => { + channels = new FakeAblyChannels([testChannelName]); + ablyClient = new FakeAblySdk().connectTo(channels); + }); + + /** @nospec */ + it('returns the channel and push methods', () => { + const { result } = renderInCtxProvider(ablyClient); + + expect(result.current.channel).toBeDefined(); + expect(result.current.subscribeDevice).toBeTypeOf('function'); + expect(result.current.unsubscribeDevice).toBeTypeOf('function'); + expect(result.current.subscribeClient).toBeTypeOf('function'); + expect(result.current.unsubscribeClient).toBeTypeOf('function'); + expect(result.current.listSubscriptions).toBeTypeOf('function'); + }); + + /** @nospec */ + it('calls channel.push.subscribeDevice when subscribeDevice is called', async () => { + const { result } = renderInCtxProvider(ablyClient); + const channel = result.current.channel; + const spy = vi.spyOn(channel.push, 'subscribeDevice'); + + await act(async () => { + await result.current.subscribeDevice(); + }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('calls channel.push.unsubscribeDevice when unsubscribeDevice is called', async () => { + const { result } = renderInCtxProvider(ablyClient); + const channel = result.current.channel; + const spy = vi.spyOn(channel.push, 'unsubscribeDevice'); + + await act(async () => { + await result.current.unsubscribeDevice(); + }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('calls channel.push.subscribeClient when subscribeClient is called', async () => { + const { result } = renderInCtxProvider(ablyClient); + const channel = result.current.channel; + const spy = vi.spyOn(channel.push, 'subscribeClient'); + + await act(async () => { + await result.current.subscribeClient(); + }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('calls channel.push.unsubscribeClient when unsubscribeClient is called', async () => { + const { result } = renderInCtxProvider(ablyClient); + const channel = result.current.channel; + const spy = vi.spyOn(channel.push, 'unsubscribeClient'); + + await act(async () => { + await result.current.unsubscribeClient(); + }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('calls channel.push.listSubscriptions with params', async () => { + const { result } = renderInCtxProvider(ablyClient); + const channel = result.current.channel; + const spy = vi.spyOn(channel.push, 'listSubscriptions'); + + await act(async () => { + await result.current.listSubscriptions({ deviceId: 'device123' }); + }); + + expect(spy).toHaveBeenCalledWith({ deviceId: 'device123' }); + }); + + /** @nospec */ + it('returns stable callback references across re-renders', () => { + const { result, rerender } = renderInCtxProvider(ablyClient); + + const firstRender = { + subscribeDevice: result.current.subscribeDevice, + unsubscribeDevice: result.current.unsubscribeDevice, + subscribeClient: result.current.subscribeClient, + unsubscribeClient: result.current.unsubscribeClient, + listSubscriptions: result.current.listSubscriptions, + }; + + rerender(); + + expect(result.current.subscribeDevice).toBe(firstRender.subscribeDevice); + expect(result.current.unsubscribeDevice).toBe(firstRender.unsubscribeDevice); + expect(result.current.subscribeClient).toBe(firstRender.subscribeClient); + expect(result.current.unsubscribeClient).toBe(firstRender.unsubscribeClient); + expect(result.current.listSubscriptions).toBe(firstRender.listSubscriptions); + }); + + /** @nospec */ + it('accepts a channel name string directly', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => usePush(testChannelName), { wrapper }); + + expect(result.current.channel).toBeDefined(); + expect(result.current.subscribeDevice).toBeTypeOf('function'); + }); +}); diff --git a/src/platform/react-hooks/src/hooks/usePush.ts b/src/platform/react-hooks/src/hooks/usePush.ts new file mode 100644 index 0000000000..0dc8cba460 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePush.ts @@ -0,0 +1,50 @@ +import type * as Ably from 'ably'; +import { useCallback } from 'react'; +import { ChannelParameters } from '../AblyReactHooks.js'; +import { useChannelInstance } from './useChannelInstance.js'; +import { useStateErrors } from './useStateErrors.js'; + +export interface PushResult { + channel: Ably.RealtimeChannel; + subscribeDevice: () => Promise; + unsubscribeDevice: () => Promise; + subscribeClient: () => Promise; + unsubscribeClient: () => Promise; + listSubscriptions: (params?: Record) => Promise>; + connectionError: Ably.ErrorInfo | null; + channelError: Ably.ErrorInfo | null; +} + +export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushResult { + const params = + typeof channelNameOrNameAndOptions === 'object' + ? channelNameOrNameAndOptions + : { channelName: channelNameOrNameAndOptions }; + + const { channel } = useChannelInstance(params.ablyId, params.channelName); + const { connectionError, channelError } = useStateErrors(params); + + // Access channel.push eagerly to fail fast if the Push plugin is not loaded. + // The getter on RealtimeChannel throws a descriptive error when the plugin is missing. + const push = channel.push; + + const subscribeDevice = useCallback(() => push.subscribeDevice(), [push]); + const unsubscribeDevice = useCallback(() => push.unsubscribeDevice(), [push]); + const subscribeClient = useCallback(() => push.subscribeClient(), [push]); + const unsubscribeClient = useCallback(() => push.unsubscribeClient(), [push]); + const listSubscriptions = useCallback( + (params?: Record) => push.listSubscriptions(params), + [push], + ); + + return { + channel, + subscribeDevice, + unsubscribeDevice, + subscribeClient, + unsubscribeClient, + listSubscriptions, + connectionError, + channelError, + }; +} diff --git a/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx b/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx new file mode 100644 index 0000000000..a273f79fc9 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type * as Ably from 'ably'; +import { it, beforeEach, describe, expect, vi } from 'vitest'; +import { usePushActivation } from './usePushActivation.js'; +import { renderHook, act } from '@testing-library/react'; +import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably.js'; +import { AblyProvider } from '../AblyProvider.js'; + +describe('usePushActivation', () => { + let channels: FakeAblyChannels; + let ablyClient: FakeAblySdk; + + beforeEach(() => { + channels = new FakeAblyChannels([]); + ablyClient = new FakeAblySdk().connectTo(channels); + // Add a fake push object to the client + (ablyClient as any).push = { + activate: vi.fn().mockResolvedValue(undefined), + deactivate: vi.fn().mockResolvedValue(undefined), + }; + }); + + function renderWithProvider() { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook(() => usePushActivation(), { wrapper }); + } + + /** @nospec */ + it('returns activate and deactivate functions', () => { + const { result } = renderWithProvider(); + + expect(result.current.activate).toBeTypeOf('function'); + expect(result.current.deactivate).toBeTypeOf('function'); + }); + + /** @nospec */ + it('calls client.push.activate when activate is called', async () => { + const { result } = renderWithProvider(); + + await act(async () => { + await result.current.activate(); + }); + + expect((ablyClient as any).push.activate).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('calls client.push.deactivate when deactivate is called', async () => { + const { result } = renderWithProvider(); + + await act(async () => { + await result.current.deactivate(); + }); + + expect((ablyClient as any).push.deactivate).toHaveBeenCalledOnce(); + }); + + /** @nospec */ + it('returns stable callback references across re-renders', () => { + const { result, rerender } = renderWithProvider(); + + const firstRender = { + activate: result.current.activate, + deactivate: result.current.deactivate, + }; + + rerender(); + + expect(result.current.activate).toBe(firstRender.activate); + expect(result.current.deactivate).toBe(firstRender.deactivate); + }); +}); diff --git a/src/platform/react-hooks/src/hooks/usePushActivation.ts b/src/platform/react-hooks/src/hooks/usePushActivation.ts new file mode 100644 index 0000000000..5eb44f228f --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePushActivation.ts @@ -0,0 +1,17 @@ +import type * as Ably from 'ably'; +import { useCallback } from 'react'; +import { useAbly } from './useAbly.js'; + +export interface PushActivationResult { + activate: () => Promise; + deactivate: () => Promise; +} + +export function usePushActivation(ablyId?: string): PushActivationResult { + const ably = useAbly(ablyId); + + const activate = useCallback(() => ably.push.activate(), [ably]); + const deactivate = useCallback(() => ably.push.deactivate(), [ably]); + + return { activate, deactivate }; +} diff --git a/src/platform/react-hooks/src/index.ts b/src/platform/react-hooks/src/index.ts index c064287468..98f549843e 100644 --- a/src/platform/react-hooks/src/index.ts +++ b/src/platform/react-hooks/src/index.ts @@ -6,5 +6,7 @@ export * from './hooks/useAbly.js'; export * from './AblyProvider.js'; export * from './hooks/useChannelStateListener.js'; export * from './hooks/useConnectionStateListener.js'; +export * from './hooks/usePush.js'; +export * from './hooks/usePushActivation.js'; export { ChannelProvider } from './ChannelProvider.js'; export * from './AblyContext.js'; From 021c82d452f1edd0ea6fac935c5745887977b18c Mon Sep 17 00:00:00 2001 From: Steven Lindsay Date: Mon, 30 Mar 2026 12:06:27 +0100 Subject: [PATCH 2/2] Add reactive localDevice and isActivated to push hooks usePushActivation now returns a reactive localDevice (LocalDevice | null) that initialises from localStorage and updates on activate/deactivate. usePush now exposes isActivated, derived from a shared store, enabling components to guard subscription calls without extra wiring. Updated docs and tests accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/react-push.md | 76 ++++++++++----- .../react-hooks/src/PushActivationState.ts | 30 ++++++ .../react-hooks/src/hooks/usePush.test.tsx | 55 ++++++++++- src/platform/react-hooks/src/hooks/usePush.ts | 20 +++- .../src/hooks/usePushActivation.test.tsx | 95 +++++++++++++++++-- .../src/hooks/usePushActivation.ts | 44 ++++++++- 6 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 src/platform/react-hooks/src/PushActivationState.ts diff --git a/docs/react-push.md b/docs/react-push.md index a1b0c0ea9a..4f29d55df4 100644 --- a/docs/react-push.md +++ b/docs/react-push.md @@ -56,10 +56,11 @@ The `usePushActivation` hook provides functions to activate and deactivate the c import { usePushActivation } from 'ably/react'; const PushActivationComponent = () => { - const { activate, deactivate } = usePushActivation(); + const { activate, deactivate, localDevice } = usePushActivation(); return (
+

Status: {localDevice ? `Activated (${localDevice.id})` : 'Not activated'}

@@ -67,6 +68,8 @@ const PushActivationComponent = () => { }; ``` +The `localDevice` property is reactive — it updates when `activate()` or `deactivate()` is called. It is also initialised from `localStorage` on mount, so if the device was activated in a prior session, `localDevice` will be populated immediately. + #### Activation lifecycle Activation registers the device with Ably's push service (on web, this requests browser notification permission and registers a service worker). The device identity is persisted to `localStorage`, so: @@ -79,19 +82,17 @@ A typical pattern is to call `activate()` once in response to a user action (e.g ```jsx const NotificationBanner = () => { - const { activate } = usePushActivation(); - const [enabled, setEnabled] = useState(false); + const { activate, localDevice } = usePushActivation(); const handleEnable = async () => { try { await activate(); - setEnabled(true); } catch (err) { console.error('Push activation failed:', err); } }; - if (enabled) return null; + if (localDevice) return null; return (
@@ -120,19 +121,28 @@ The `usePush` hook provides functions to manage push notification subscriptions import { usePush } from 'ably/react'; const PushSubscriptionComponent = () => { - const { subscribeDevice, unsubscribeDevice } = usePush('your-channel-name'); + const { subscribeDevice, unsubscribeDevice, isActivated } = usePush('your-channel-name'); return (
- - + + + {!isActivated &&

Push must be activated before subscribing.

}
); }; ``` +#### Activation awareness + +`usePush` is aware of whether push has been activated via `usePushActivation`. The `isActivated` property is reactive — when `usePushActivation` calls `activate()` or `deactivate()`, all `usePush` instances update automatically, even if they are in different components. This works via a shared store without requiring any additional providers. + > [!IMPORTANT] -> The device must be activated (via `usePushActivation`) before calling `subscribeDevice()` or `unsubscribeDevice()`. See [Error Handling](#error-handling) for details on what happens if activation hasn't been completed. +> The device must be activated (via `usePushActivation`) before calling `subscribeDevice()` or `unsubscribeDevice()`. Use `isActivated` to guard your UI or check before calling. See [Error Handling](#error-handling) for details on what happens if activation hasn't been completed. #### Subscribe by device or by client @@ -202,7 +212,18 @@ If you call `subscribeDevice()` or `unsubscribeDevice()` before the device has b Error: Cannot subscribe from client without deviceIdentityToken (code: 50000) ``` -Ensure `activate()` has completed successfully before subscribing: +The recommended way to prevent this is to use the `isActivated` flag from `usePush` to guard your UI: + +```jsx +const { subscribeDevice, isActivated } = usePush('alerts'); + +// Disable the button until push is activated + +``` + +Alternatively, you can sequence activation and subscription imperatively: ```jsx const { activate } = usePushActivation(); @@ -266,18 +287,16 @@ const App = () => ( ); const PushActivation = () => { - const { activate, deactivate } = usePushActivation(); - const [active, setActive] = useState(false); + const { activate, deactivate, localDevice } = usePushActivation(); const [error, setError] = useState(null); const handleToggle = async () => { try { - if (active) { + if (localDevice) { await deactivate(); } else { await activate(); } - setActive(!active); setError(null); } catch (err) { setError(err.message); @@ -287,15 +306,19 @@ const PushActivation = () => { return (
+ {localDevice &&

Device ID: {localDevice.id}

} {error &&

{error}

}
); }; const AlertSubscription = () => { - const { subscribeDevice, unsubscribeDevice, connectionError, channelError } = usePush('alerts'); + const { + subscribeDevice, unsubscribeDevice, + isActivated, connectionError, channelError, + } = usePush('alerts'); const [subscribed, setSubscribed] = useState(false); if (connectionError) return

Connection error: {connectionError.message}

; @@ -315,9 +338,12 @@ const AlertSubscription = () => { }; return ( - +
+ + {!isActivated &&

Activate push notifications first.

} +
); }; ``` @@ -334,13 +360,15 @@ function usePushActivation(ablyId?: string): PushActivationResult; interface PushActivationResult { activate: () => Promise; deactivate: () => Promise; + localDevice: Ably.LocalDevice | null; } ``` -| Property | Type | Description | -| ------------ | --------------------- | --------------------------------------------------------------------------- | -| `activate` | `() => Promise` | Activates the device for push notifications. Persists to `localStorage`. | -| `deactivate` | `() => Promise` | Deactivates the device and removes the registration from Ably's servers. | +| Property | Type | Description | +| ------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- | +| `activate` | `() => Promise` | Activates the device for push notifications. Persists to `localStorage`. | +| `deactivate` | `() => Promise` | Deactivates the device and removes the registration from Ably's servers. | +| `localDevice` | `Ably.LocalDevice\|null` | The current device if activated, `null` otherwise. Reactive — updates on activate/deactivate and is initialised from persisted state. | ### `usePush` @@ -354,6 +382,7 @@ interface PushResult { subscribeClient: () => Promise; unsubscribeClient: () => Promise; listSubscriptions: (params?: Record) => Promise>; + isActivated: boolean; connectionError: Ably.ErrorInfo | null; channelError: Ably.ErrorInfo | null; } @@ -367,5 +396,6 @@ interface PushResult { | `subscribeClient` | `() => Promise` | Subscribes all devices for the current `clientId` to push on this channel. | | `unsubscribeClient` | `() => Promise` | Unsubscribes all devices for the current `clientId` from push on this channel. | | `listSubscriptions` | `(params?) => Promise>` | Lists active push subscriptions for this channel. | +| `isActivated` | `boolean` | Whether push is currently activated. Reactive — updates across components. | | `connectionError` | `Ably.ErrorInfo \| null` | Current connection error, if any. | | `channelError` | `Ably.ErrorInfo \| null` | Current channel error, if any. | diff --git a/src/platform/react-hooks/src/PushActivationState.ts b/src/platform/react-hooks/src/PushActivationState.ts new file mode 100644 index 0000000000..20b5dc0486 --- /dev/null +++ b/src/platform/react-hooks/src/PushActivationState.ts @@ -0,0 +1,30 @@ +import type * as Ably from 'ably'; + +type Listener = () => void; + +const listeners = new Map>(); +const deviceState = new Map(); + +export function getActivatedDevice(ablyId: string): Ably.LocalDevice | null { + return deviceState.get(ablyId) ?? null; +} + +export function setActivatedDevice(ablyId: string, device: Ably.LocalDevice | null): void { + deviceState.set(ablyId, device); + const ablyListeners = listeners.get(ablyId); + if (ablyListeners) { + for (const listener of ablyListeners) { + listener(); + } + } +} + +export function subscribe(ablyId: string, listener: Listener): () => void { + if (!listeners.has(ablyId)) { + listeners.set(ablyId, new Set()); + } + listeners.get(ablyId)!.add(listener); + return () => { + listeners.get(ablyId)?.delete(listener); + }; +} diff --git a/src/platform/react-hooks/src/hooks/usePush.test.tsx b/src/platform/react-hooks/src/hooks/usePush.test.tsx index 6d467943e8..21157f7e1e 100644 --- a/src/platform/react-hooks/src/hooks/usePush.test.tsx +++ b/src/platform/react-hooks/src/hooks/usePush.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; import type * as Ably from 'ably'; -import { it, beforeEach, describe, expect, vi } from 'vitest'; +import { it, beforeEach, afterEach, describe, expect, vi } from 'vitest'; import { usePush } from './usePush.js'; import { renderHook, act } from '@testing-library/react'; import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably.js'; import { AblyProvider } from '../AblyProvider.js'; import { ChannelProvider } from '../ChannelProvider.js'; +import { setActivatedDevice } from '../PushActivationState.js'; const testChannelName = 'testChannel'; @@ -28,8 +29,12 @@ describe('usePush', () => { ablyClient = new FakeAblySdk().connectTo(channels); }); + afterEach(() => { + setActivatedDevice('default', null); + }); + /** @nospec */ - it('returns the channel and push methods', () => { + it('returns the channel, push methods, and isActivated', () => { const { result } = renderInCtxProvider(ablyClient); expect(result.current.channel).toBeDefined(); @@ -38,6 +43,52 @@ describe('usePush', () => { expect(result.current.subscribeClient).toBeTypeOf('function'); expect(result.current.unsubscribeClient).toBeTypeOf('function'); expect(result.current.listSubscriptions).toBeTypeOf('function'); + expect(result.current).toHaveProperty('isActivated'); + }); + + /** @nospec */ + it('isActivated is false when no device is activated', () => { + const { result } = renderInCtxProvider(ablyClient); + + expect(result.current.isActivated).toBe(false); + }); + + /** @nospec */ + it('isActivated becomes true when device is activated via store', () => { + const { result } = renderInCtxProvider(ablyClient); + + expect(result.current.isActivated).toBe(false); + + act(() => { + setActivatedDevice('default', { + id: 'device-123', + deviceSecret: 'secret-456', + deviceIdentityToken: 'token-789', + listSubscriptions: vi.fn() as any, + }); + }); + + expect(result.current.isActivated).toBe(true); + }); + + /** @nospec */ + it('isActivated reverts to false when device is deactivated via store', () => { + setActivatedDevice('default', { + id: 'device-123', + deviceSecret: 'secret-456', + deviceIdentityToken: 'token-789', + listSubscriptions: vi.fn() as any, + }); + + const { result } = renderInCtxProvider(ablyClient); + + expect(result.current.isActivated).toBe(true); + + act(() => { + setActivatedDevice('default', null); + }); + + expect(result.current.isActivated).toBe(false); }); /** @nospec */ diff --git a/src/platform/react-hooks/src/hooks/usePush.ts b/src/platform/react-hooks/src/hooks/usePush.ts index 0dc8cba460..d5e6aaba54 100644 --- a/src/platform/react-hooks/src/hooks/usePush.ts +++ b/src/platform/react-hooks/src/hooks/usePush.ts @@ -1,8 +1,9 @@ import type * as Ably from 'ably'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { ChannelParameters } from '../AblyReactHooks.js'; import { useChannelInstance } from './useChannelInstance.js'; import { useStateErrors } from './useStateErrors.js'; +import { getActivatedDevice, subscribe } from '../PushActivationState.js'; export interface PushResult { channel: Ably.RealtimeChannel; @@ -11,6 +12,7 @@ export interface PushResult { subscribeClient: () => Promise; unsubscribeClient: () => Promise; listSubscriptions: (params?: Record) => Promise>; + isActivated: boolean; connectionError: Ably.ErrorInfo | null; channelError: Ably.ErrorInfo | null; } @@ -21,13 +23,26 @@ export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushRes ? channelNameOrNameAndOptions : { channelName: channelNameOrNameAndOptions }; - const { channel } = useChannelInstance(params.ablyId, params.channelName); + const ablyId = params.ablyId ?? 'default'; + + const { channel } = useChannelInstance(ablyId, params.channelName); const { connectionError, channelError } = useStateErrors(params); // Access channel.push eagerly to fail fast if the Push plugin is not loaded. // The getter on RealtimeChannel throws a descriptive error when the plugin is missing. const push = channel.push; + // Subscribe to push activation state from the shared store + const [localDevice, setLocalDevice] = useState(() => getActivatedDevice(ablyId)); + + useEffect(() => { + return subscribe(ablyId, () => { + setLocalDevice(getActivatedDevice(ablyId)); + }); + }, [ablyId]); + + const isActivated = localDevice != null; + const subscribeDevice = useCallback(() => push.subscribeDevice(), [push]); const unsubscribeDevice = useCallback(() => push.unsubscribeDevice(), [push]); const subscribeClient = useCallback(() => push.subscribeClient(), [push]); @@ -44,6 +59,7 @@ export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushRes subscribeClient, unsubscribeClient, listSubscriptions, + isActivated, connectionError, channelError, }; diff --git a/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx b/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx index a273f79fc9..e9666f9af2 100644 --- a/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx +++ b/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx @@ -1,10 +1,18 @@ import React from 'react'; import type * as Ably from 'ably'; -import { it, beforeEach, describe, expect, vi } from 'vitest'; +import { it, beforeEach, afterEach, describe, expect, vi } from 'vitest'; import { usePushActivation } from './usePushActivation.js'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably.js'; import { AblyProvider } from '../AblyProvider.js'; +import { setActivatedDevice } from '../PushActivationState.js'; + +const fakeDevice: Ably.LocalDevice = { + id: 'device-123', + deviceSecret: 'secret-456', + deviceIdentityToken: 'token-789', + listSubscriptions: vi.fn() as any, +}; describe('usePushActivation', () => { let channels: FakeAblyChannels; @@ -13,11 +21,15 @@ describe('usePushActivation', () => { beforeEach(() => { channels = new FakeAblyChannels([]); ablyClient = new FakeAblySdk().connectTo(channels); - // Add a fake push object to the client (ablyClient as any).push = { activate: vi.fn().mockResolvedValue(undefined), deactivate: vi.fn().mockResolvedValue(undefined), }; + (ablyClient as any).device = vi.fn().mockReturnValue(fakeDevice); + }); + + afterEach(() => { + setActivatedDevice('default', null); }); function renderWithProvider() { @@ -29,11 +41,76 @@ describe('usePushActivation', () => { } /** @nospec */ - it('returns activate and deactivate functions', () => { + it('returns activate, deactivate and localDevice', async () => { + const { result } = renderWithProvider(); + + await waitFor(() => { + expect(result.current.activate).toBeTypeOf('function'); + expect(result.current.deactivate).toBeTypeOf('function'); + expect(result.current).toHaveProperty('localDevice'); + }); + }); + + /** @nospec */ + it('localDevice is populated from persisted state on mount', async () => { const { result } = renderWithProvider(); - expect(result.current.activate).toBeTypeOf('function'); - expect(result.current.deactivate).toBeTypeOf('function'); + await waitFor(() => { + expect(result.current.localDevice).toEqual(fakeDevice); + }); + }); + + /** @nospec */ + it('localDevice is null when device has no identity token', async () => { + (ablyClient as any).device = vi.fn().mockReturnValue({ + id: 'device-123', + deviceSecret: 'secret-456', + deviceIdentityToken: undefined, + }); + + const { result } = renderWithProvider(); + + await waitFor(() => { + expect(result.current.localDevice).toBeNull(); + }); + }); + + /** @nospec */ + it('localDevice updates after activate is called', async () => { + (ablyClient as any).device = vi.fn().mockReturnValue({ + id: 'device-123', + deviceSecret: 'secret-456', + deviceIdentityToken: undefined, + }); + + const { result } = renderWithProvider(); + + await waitFor(() => { + expect(result.current.localDevice).toBeNull(); + }); + + (ablyClient as any).device = vi.fn().mockReturnValue(fakeDevice); + + await act(async () => { + await result.current.activate(); + }); + + expect(result.current.localDevice).toEqual(fakeDevice); + }); + + /** @nospec */ + it('localDevice becomes null after deactivate is called', async () => { + const { result } = renderWithProvider(); + + await waitFor(() => { + expect(result.current.localDevice).toEqual(fakeDevice); + }); + + await act(async () => { + await result.current.deactivate(); + }); + + expect(result.current.localDevice).toBeNull(); }); /** @nospec */ @@ -59,9 +136,13 @@ describe('usePushActivation', () => { }); /** @nospec */ - it('returns stable callback references across re-renders', () => { + it('returns stable callback references across re-renders', async () => { const { result, rerender } = renderWithProvider(); + await waitFor(() => { + expect(result.current.activate).toBeTypeOf('function'); + }); + const firstRender = { activate: result.current.activate, deactivate: result.current.deactivate, diff --git a/src/platform/react-hooks/src/hooks/usePushActivation.ts b/src/platform/react-hooks/src/hooks/usePushActivation.ts index 5eb44f228f..cb2aca8588 100644 --- a/src/platform/react-hooks/src/hooks/usePushActivation.ts +++ b/src/platform/react-hooks/src/hooks/usePushActivation.ts @@ -1,17 +1,51 @@ import type * as Ably from 'ably'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useAbly } from './useAbly.js'; +import { getActivatedDevice, setActivatedDevice, subscribe } from '../PushActivationState.js'; export interface PushActivationResult { activate: () => Promise; deactivate: () => Promise; + localDevice: Ably.LocalDevice | null; } -export function usePushActivation(ablyId?: string): PushActivationResult { +export function usePushActivation(ablyId: string = 'default'): PushActivationResult { const ably = useAbly(ablyId); - const activate = useCallback(() => ably.push.activate(), [ably]); - const deactivate = useCallback(() => ably.push.deactivate(), [ably]); + // Initialise the store from persisted device state on first render. + // client.device() reads from localStorage, so if the device was activated + // in a prior session it will already have a deviceIdentityToken. + const initialized = useRef(false); + if (!initialized.current) { + initialized.current = true; + try { + const device = ably.device(); + if (device.deviceIdentityToken) { + setActivatedDevice(ablyId, device); + } + } catch { + // Push plugin not loaded — leave as null + } + } - return { activate, deactivate }; + // Subscribe to the shared store for reactive updates + const [localDevice, setLocalDevice] = useState(() => getActivatedDevice(ablyId)); + + useEffect(() => { + return subscribe(ablyId, () => { + setLocalDevice(getActivatedDevice(ablyId)); + }); + }, [ablyId]); + + const activate = useCallback(async () => { + await ably.push.activate(); + setActivatedDevice(ablyId, ably.device()); + }, [ably, ablyId]); + + const deactivate = useCallback(async () => { + await ably.push.deactivate(); + setActivatedDevice(ablyId, null); + }, [ably, ablyId]); + + return { activate, deactivate, localDevice }; }