diff --git a/docs/react-push.md b/docs/react-push.md new file mode 100644 index 0000000000..4f29d55df4 --- /dev/null +++ b/docs/react-push.md @@ -0,0 +1,401 @@ +# 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, localDevice } = usePushActivation(); + + return ( +
+

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

+ + +
+ ); +}; +``` + +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: + +- **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, localDevice } = usePushActivation(); + + const handleEnable = async () => { + try { + await activate(); + } catch (err) { + console.error('Push activation failed:', err); + } + }; + + if (localDevice) 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, 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()`. 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 + +`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) +``` + +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(); +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, localDevice } = usePushActivation(); + const [error, setError] = useState(null); + + const handleToggle = async () => { + try { + if (localDevice) { + await deactivate(); + } else { + await activate(); + } + setError(null); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+ + {localDevice &&

Device ID: {localDevice.id}

} + {error &&

{error}

} +
+ ); +}; + +const AlertSubscription = () => { + const { + subscribeDevice, unsubscribeDevice, + isActivated, 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 ( +
+ + {!isActivated &&

Activate push notifications first.

} +
+ ); +}; +``` + +--- + +## API Reference + +### `usePushActivation` + +```typescript +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. | +| `localDevice` | `Ably.LocalDevice\|null` | The current device if activated, `null` otherwise. Reactive — updates on activate/deactivate and is initialised from persisted state. | + +### `usePush` + +```typescript +function usePush(channelNameOrNameAndOptions: ChannelParameters): PushResult; + +interface PushResult { + channel: Ably.RealtimeChannel; + subscribeDevice: () => Promise; + unsubscribeDevice: () => Promise; + subscribeClient: () => Promise; + unsubscribeClient: () => Promise; + listSubscriptions: (params?: Record) => Promise>; + isActivated: boolean; + 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. | +| `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/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..21157f7e1e --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePush.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import type * as Ably from 'ably'; +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'; + +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); + }); + + afterEach(() => { + setActivatedDevice('default', null); + }); + + /** @nospec */ + it('returns the channel, push methods, and isActivated', () => { + 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'); + 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 */ + 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..d5e6aaba54 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePush.ts @@ -0,0 +1,66 @@ +import type * as Ably from 'ably'; +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; + subscribeDevice: () => Promise; + unsubscribeDevice: () => Promise; + subscribeClient: () => Promise; + unsubscribeClient: () => Promise; + listSubscriptions: (params?: Record) => Promise>; + isActivated: boolean; + connectionError: Ably.ErrorInfo | null; + channelError: Ably.ErrorInfo | null; +} + +export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushResult { + const params = + typeof channelNameOrNameAndOptions === 'object' + ? channelNameOrNameAndOptions + : { channelName: channelNameOrNameAndOptions }; + + 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]); + const unsubscribeClient = useCallback(() => push.unsubscribeClient(), [push]); + const listSubscriptions = useCallback( + (params?: Record) => push.listSubscriptions(params), + [push], + ); + + return { + channel, + subscribeDevice, + unsubscribeDevice, + 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 new file mode 100644 index 0000000000..e9666f9af2 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePushActivation.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import type * as Ably from 'ably'; +import { it, beforeEach, afterEach, describe, expect, vi } from 'vitest'; +import { usePushActivation } from './usePushActivation.js'; +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; + let ablyClient: FakeAblySdk; + + beforeEach(() => { + channels = new FakeAblyChannels([]); + ablyClient = new FakeAblySdk().connectTo(channels); + (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() { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook(() => usePushActivation(), { wrapper }); + } + + /** @nospec */ + 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(); + + 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 */ + 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', async () => { + const { result, rerender } = renderWithProvider(); + + await waitFor(() => { + expect(result.current.activate).toBeTypeOf('function'); + }); + + 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..cb2aca8588 --- /dev/null +++ b/src/platform/react-hooks/src/hooks/usePushActivation.ts @@ -0,0 +1,51 @@ +import type * as Ably from 'ably'; +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 = 'default'): PushActivationResult { + const ably = useAbly(ablyId); + + // 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 + } + } + + // 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 }; +} 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';