Skip to content
Draft
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
401 changes: 401 additions & 0 deletions docs/react-push.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/platform/react-hooks/src/PushActivationState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type * as Ably from 'ably';

type Listener = () => void;

const listeners = new Map<string, Set<Listener>>();
const deviceState = new Map<string, Ably.LocalDevice | null>();

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);

Check warning on line 26 in src/platform/react-hooks/src/PushActivationState.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
return () => {
listeners.get(ablyId)?.delete(listener);
};
}
24 changes: 24 additions & 0 deletions src/platform/react-hooks/src/fakes/ably.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
private channel: Channel;

public presence: any;
public push: any;
public state: string;
public name: string;

Expand All @@ -134,6 +135,7 @@
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;
}
Expand Down Expand Up @@ -164,7 +166,7 @@
// do nothing
}

public async history(_params?: any) {

Check warning on line 169 in src/platform/react-hooks/src/fakes/ably.ts

View workflow job for this annotation

GitHub Actions / lint

'_params' is defined but never used
return {
items: this.channel.publishedMessages.map((m: any) => m.messageEnvelope),
hasNext: () => false,
Expand Down Expand Up @@ -215,7 +217,7 @@
throw Error('no publish for derived channel');
}

public async history(_params?: any) {

Check warning on line 220 in src/platform/react-hooks/src/fakes/ably.ts

View workflow job for this annotation

GitHub Actions / lint

'_params' is defined but never used
return {
items: this.channel.publishedMessages.map((m: any) => m.messageEnvelope),
hasNext: () => false,
Expand Down Expand Up @@ -446,3 +448,25 @@
}
}
}

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<string, string>) {

Check warning on line 469 in src/platform/react-hooks/src/fakes/ably.ts

View workflow job for this annotation

GitHub Actions / lint

'_params' is defined but never used
return { items: [], hasNext: () => false, isLast: () => true } as any;
}
}
193 changes: 193 additions & 0 deletions src/platform/react-hooks/src/hooks/usePush.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<AblyProvider client={client as unknown as Ably.RealtimeClient}>
<ChannelProvider channelName={testChannelName}>{children}</ChannelProvider>
</AblyProvider>
);

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 }) => (
<AblyProvider client={ablyClient as unknown as Ably.RealtimeClient}>
<ChannelProvider channelName={testChannelName}>{children}</ChannelProvider>
</AblyProvider>
);

const { result } = renderHook(() => usePush(testChannelName), { wrapper });

expect(result.current.channel).toBeDefined();
expect(result.current.subscribeDevice).toBeTypeOf('function');
});
});
66 changes: 66 additions & 0 deletions src/platform/react-hooks/src/hooks/usePush.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
unsubscribeDevice: () => Promise<void>;
subscribeClient: () => Promise<void>;
unsubscribeClient: () => Promise<void>;
listSubscriptions: (params?: Record<string, string>) => Promise<Ably.PaginatedResult<Ably.PushChannelSubscription>>;
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<Ably.LocalDevice | null>(() => 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<string, string>) => push.listSubscriptions(params),
[push],
);

return {
channel,
subscribeDevice,
unsubscribeDevice,
subscribeClient,
unsubscribeClient,
listSubscriptions,
isActivated,
connectionError,
channelError,
};
}
Loading
Loading