Skip to content

Commit 021c82d

Browse files
splindsay-92claude
andcommitted
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) <noreply@anthropic.com>
1 parent 2bdd976 commit 021c82d

File tree

6 files changed

+281
-39
lines changed

6 files changed

+281
-39
lines changed

docs/react-push.md

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,20 @@ The `usePushActivation` hook provides functions to activate and deactivate the c
5656
import { usePushActivation } from 'ably/react';
5757

5858
const PushActivationComponent = () => {
59-
const { activate, deactivate } = usePushActivation();
59+
const { activate, deactivate, localDevice } = usePushActivation();
6060

6161
return (
6262
<div>
63+
<p>Status: {localDevice ? `Activated (${localDevice.id})` : 'Not activated'}</p>
6364
<button onClick={() => activate()}>Enable push notifications</button>
6465
<button onClick={() => deactivate()}>Disable push notifications</button>
6566
</div>
6667
);
6768
};
6869
```
6970

71+
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.
72+
7073
#### Activation lifecycle
7174

7275
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
7982

8083
```jsx
8184
const NotificationBanner = () => {
82-
const { activate } = usePushActivation();
83-
const [enabled, setEnabled] = useState(false);
85+
const { activate, localDevice } = usePushActivation();
8486

8587
const handleEnable = async () => {
8688
try {
8789
await activate();
88-
setEnabled(true);
8990
} catch (err) {
9091
console.error('Push activation failed:', err);
9192
}
9293
};
9394

94-
if (enabled) return null;
95+
if (localDevice) return null;
9596

9697
return (
9798
<div className="banner">
@@ -120,19 +121,28 @@ The `usePush` hook provides functions to manage push notification subscriptions
120121
import { usePush } from 'ably/react';
121122

122123
const PushSubscriptionComponent = () => {
123-
const { subscribeDevice, unsubscribeDevice } = usePush('your-channel-name');
124+
const { subscribeDevice, unsubscribeDevice, isActivated } = usePush('your-channel-name');
124125

125126
return (
126127
<div>
127-
<button onClick={() => subscribeDevice()}>Subscribe to channel</button>
128-
<button onClick={() => unsubscribeDevice()}>Unsubscribe from channel</button>
128+
<button onClick={() => subscribeDevice()} disabled={!isActivated}>
129+
Subscribe to channel
130+
</button>
131+
<button onClick={() => unsubscribeDevice()} disabled={!isActivated}>
132+
Unsubscribe from channel
133+
</button>
134+
{!isActivated && <p>Push must be activated before subscribing.</p>}
129135
</div>
130136
);
131137
};
132138
```
133139

140+
#### Activation awareness
141+
142+
`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.
143+
134144
> [!IMPORTANT]
135-
> 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.
145+
> 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.
136146
137147
#### Subscribe by device or by client
138148

@@ -202,7 +212,18 @@ If you call `subscribeDevice()` or `unsubscribeDevice()` before the device has b
202212
Error: Cannot subscribe from client without deviceIdentityToken (code: 50000)
203213
```
204214

205-
Ensure `activate()` has completed successfully before subscribing:
215+
The recommended way to prevent this is to use the `isActivated` flag from `usePush` to guard your UI:
216+
217+
```jsx
218+
const { subscribeDevice, isActivated } = usePush('alerts');
219+
220+
// Disable the button until push is activated
221+
<button onClick={() => subscribeDevice()} disabled={!isActivated}>
222+
Subscribe
223+
</button>
224+
```
225+
226+
Alternatively, you can sequence activation and subscription imperatively:
206227

207228
```jsx
208229
const { activate } = usePushActivation();
@@ -266,18 +287,16 @@ const App = () => (
266287
);
267288

268289
const PushActivation = () => {
269-
const { activate, deactivate } = usePushActivation();
270-
const [active, setActive] = useState(false);
290+
const { activate, deactivate, localDevice } = usePushActivation();
271291
const [error, setError] = useState(null);
272292

273293
const handleToggle = async () => {
274294
try {
275-
if (active) {
295+
if (localDevice) {
276296
await deactivate();
277297
} else {
278298
await activate();
279299
}
280-
setActive(!active);
281300
setError(null);
282301
} catch (err) {
283302
setError(err.message);
@@ -287,15 +306,19 @@ const PushActivation = () => {
287306
return (
288307
<div>
289308
<button onClick={handleToggle}>
290-
{active ? 'Disable' : 'Enable'} push notifications
309+
{localDevice ? 'Disable' : 'Enable'} push notifications
291310
</button>
311+
{localDevice && <p>Device ID: {localDevice.id}</p>}
292312
{error && <p className="error">{error}</p>}
293313
</div>
294314
);
295315
};
296316

297317
const AlertSubscription = () => {
298-
const { subscribeDevice, unsubscribeDevice, connectionError, channelError } = usePush('alerts');
318+
const {
319+
subscribeDevice, unsubscribeDevice,
320+
isActivated, connectionError, channelError,
321+
} = usePush('alerts');
299322
const [subscribed, setSubscribed] = useState(false);
300323

301324
if (connectionError) return <p>Connection error: {connectionError.message}</p>;
@@ -315,9 +338,12 @@ const AlertSubscription = () => {
315338
};
316339

317340
return (
318-
<button onClick={handleToggle}>
319-
{subscribed ? 'Unsubscribe from' : 'Subscribe to'} alerts
320-
</button>
341+
<div>
342+
<button onClick={handleToggle} disabled={!isActivated}>
343+
{subscribed ? 'Unsubscribe from' : 'Subscribe to'} alerts
344+
</button>
345+
{!isActivated && <p>Activate push notifications first.</p>}
346+
</div>
321347
);
322348
};
323349
```
@@ -334,13 +360,15 @@ function usePushActivation(ablyId?: string): PushActivationResult;
334360
interface PushActivationResult {
335361
activate: () => Promise<void>;
336362
deactivate: () => Promise<void>;
363+
localDevice: Ably.LocalDevice | null;
337364
}
338365
```
339366

340-
| Property | Type | Description |
341-
| ------------ | --------------------- | --------------------------------------------------------------------------- |
342-
| `activate` | `() => Promise<void>` | Activates the device for push notifications. Persists to `localStorage`. |
343-
| `deactivate` | `() => Promise<void>` | Deactivates the device and removes the registration from Ably's servers. |
367+
| Property | Type | Description |
368+
| ------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- |
369+
| `activate` | `() => Promise<void>` | Activates the device for push notifications. Persists to `localStorage`. |
370+
| `deactivate` | `() => Promise<void>` | Deactivates the device and removes the registration from Ably's servers. |
371+
| `localDevice` | `Ably.LocalDevice\|null` | The current device if activated, `null` otherwise. Reactive — updates on activate/deactivate and is initialised from persisted state. |
344372

345373
### `usePush`
346374

@@ -354,6 +382,7 @@ interface PushResult {
354382
subscribeClient: () => Promise<void>;
355383
unsubscribeClient: () => Promise<void>;
356384
listSubscriptions: (params?: Record<string, string>) => Promise<PaginatedResult<PushChannelSubscription>>;
385+
isActivated: boolean;
357386
connectionError: Ably.ErrorInfo | null;
358387
channelError: Ably.ErrorInfo | null;
359388
}
@@ -367,5 +396,6 @@ interface PushResult {
367396
| `subscribeClient` | `() => Promise<void>` | Subscribes all devices for the current `clientId` to push on this channel. |
368397
| `unsubscribeClient` | `() => Promise<void>` | Unsubscribes all devices for the current `clientId` from push on this channel. |
369398
| `listSubscriptions` | `(params?) => Promise<PaginatedResult<...>>` | Lists active push subscriptions for this channel. |
399+
| `isActivated` | `boolean` | Whether push is currently activated. Reactive — updates across components. |
370400
| `connectionError` | `Ably.ErrorInfo \| null` | Current connection error, if any. |
371401
| `channelError` | `Ably.ErrorInfo \| null` | Current channel error, if any. |
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type * as Ably from 'ably';
2+
3+
type Listener = () => void;
4+
5+
const listeners = new Map<string, Set<Listener>>();
6+
const deviceState = new Map<string, Ably.LocalDevice | null>();
7+
8+
export function getActivatedDevice(ablyId: string): Ably.LocalDevice | null {
9+
return deviceState.get(ablyId) ?? null;
10+
}
11+
12+
export function setActivatedDevice(ablyId: string, device: Ably.LocalDevice | null): void {
13+
deviceState.set(ablyId, device);
14+
const ablyListeners = listeners.get(ablyId);
15+
if (ablyListeners) {
16+
for (const listener of ablyListeners) {
17+
listener();
18+
}
19+
}
20+
}
21+
22+
export function subscribe(ablyId: string, listener: Listener): () => void {
23+
if (!listeners.has(ablyId)) {
24+
listeners.set(ablyId, new Set());
25+
}
26+
listeners.get(ablyId)!.add(listener);
27+
return () => {
28+
listeners.get(ablyId)?.delete(listener);
29+
};
30+
}

src/platform/react-hooks/src/hooks/usePush.test.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from 'react';
22
import type * as Ably from 'ably';
3-
import { it, beforeEach, describe, expect, vi } from 'vitest';
3+
import { it, beforeEach, afterEach, describe, expect, vi } from 'vitest';
44
import { usePush } from './usePush.js';
55
import { renderHook, act } from '@testing-library/react';
66
import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably.js';
77
import { AblyProvider } from '../AblyProvider.js';
88
import { ChannelProvider } from '../ChannelProvider.js';
9+
import { setActivatedDevice } from '../PushActivationState.js';
910

1011
const testChannelName = 'testChannel';
1112

@@ -28,8 +29,12 @@ describe('usePush', () => {
2829
ablyClient = new FakeAblySdk().connectTo(channels);
2930
});
3031

32+
afterEach(() => {
33+
setActivatedDevice('default', null);
34+
});
35+
3136
/** @nospec */
32-
it('returns the channel and push methods', () => {
37+
it('returns the channel, push methods, and isActivated', () => {
3338
const { result } = renderInCtxProvider(ablyClient);
3439

3540
expect(result.current.channel).toBeDefined();
@@ -38,6 +43,52 @@ describe('usePush', () => {
3843
expect(result.current.subscribeClient).toBeTypeOf('function');
3944
expect(result.current.unsubscribeClient).toBeTypeOf('function');
4045
expect(result.current.listSubscriptions).toBeTypeOf('function');
46+
expect(result.current).toHaveProperty('isActivated');
47+
});
48+
49+
/** @nospec */
50+
it('isActivated is false when no device is activated', () => {
51+
const { result } = renderInCtxProvider(ablyClient);
52+
53+
expect(result.current.isActivated).toBe(false);
54+
});
55+
56+
/** @nospec */
57+
it('isActivated becomes true when device is activated via store', () => {
58+
const { result } = renderInCtxProvider(ablyClient);
59+
60+
expect(result.current.isActivated).toBe(false);
61+
62+
act(() => {
63+
setActivatedDevice('default', {
64+
id: 'device-123',
65+
deviceSecret: 'secret-456',
66+
deviceIdentityToken: 'token-789',
67+
listSubscriptions: vi.fn() as any,
68+
});
69+
});
70+
71+
expect(result.current.isActivated).toBe(true);
72+
});
73+
74+
/** @nospec */
75+
it('isActivated reverts to false when device is deactivated via store', () => {
76+
setActivatedDevice('default', {
77+
id: 'device-123',
78+
deviceSecret: 'secret-456',
79+
deviceIdentityToken: 'token-789',
80+
listSubscriptions: vi.fn() as any,
81+
});
82+
83+
const { result } = renderInCtxProvider(ablyClient);
84+
85+
expect(result.current.isActivated).toBe(true);
86+
87+
act(() => {
88+
setActivatedDevice('default', null);
89+
});
90+
91+
expect(result.current.isActivated).toBe(false);
4192
});
4293

4394
/** @nospec */

src/platform/react-hooks/src/hooks/usePush.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type * as Ably from 'ably';
2-
import { useCallback } from 'react';
2+
import { useCallback, useEffect, useState } from 'react';
33
import { ChannelParameters } from '../AblyReactHooks.js';
44
import { useChannelInstance } from './useChannelInstance.js';
55
import { useStateErrors } from './useStateErrors.js';
6+
import { getActivatedDevice, subscribe } from '../PushActivationState.js';
67

78
export interface PushResult {
89
channel: Ably.RealtimeChannel;
@@ -11,6 +12,7 @@ export interface PushResult {
1112
subscribeClient: () => Promise<void>;
1213
unsubscribeClient: () => Promise<void>;
1314
listSubscriptions: (params?: Record<string, string>) => Promise<Ably.PaginatedResult<Ably.PushChannelSubscription>>;
15+
isActivated: boolean;
1416
connectionError: Ably.ErrorInfo | null;
1517
channelError: Ably.ErrorInfo | null;
1618
}
@@ -21,13 +23,26 @@ export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushRes
2123
? channelNameOrNameAndOptions
2224
: { channelName: channelNameOrNameAndOptions };
2325

24-
const { channel } = useChannelInstance(params.ablyId, params.channelName);
26+
const ablyId = params.ablyId ?? 'default';
27+
28+
const { channel } = useChannelInstance(ablyId, params.channelName);
2529
const { connectionError, channelError } = useStateErrors(params);
2630

2731
// Access channel.push eagerly to fail fast if the Push plugin is not loaded.
2832
// The getter on RealtimeChannel throws a descriptive error when the plugin is missing.
2933
const push = channel.push;
3034

35+
// Subscribe to push activation state from the shared store
36+
const [localDevice, setLocalDevice] = useState<Ably.LocalDevice | null>(() => getActivatedDevice(ablyId));
37+
38+
useEffect(() => {
39+
return subscribe(ablyId, () => {
40+
setLocalDevice(getActivatedDevice(ablyId));
41+
});
42+
}, [ablyId]);
43+
44+
const isActivated = localDevice != null;
45+
3146
const subscribeDevice = useCallback(() => push.subscribeDevice(), [push]);
3247
const unsubscribeDevice = useCallback(() => push.unsubscribeDevice(), [push]);
3348
const subscribeClient = useCallback(() => push.subscribeClient(), [push]);
@@ -44,6 +59,7 @@ export function usePush(channelNameOrNameAndOptions: ChannelParameters): PushRes
4459
subscribeClient,
4560
unsubscribeClient,
4661
listSubscriptions,
62+
isActivated,
4763
connectionError,
4864
channelError,
4965
};

0 commit comments

Comments
 (0)