Skip to content

Commit

Permalink
Split usePresence hook into two hooks for entering presence and sub…
Browse files Browse the repository at this point in the history
…scribing to presence events
  • Loading branch information
VeskeR committed Mar 13, 2024
1 parent 72059ed commit 730c621
Show file tree
Hide file tree
Showing 6 changed files with 449 additions and 118 deletions.
1 change: 0 additions & 1 deletion src/platform/react-hooks/src/AblyReactHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as Ably from 'ably';
export type ChannelNameAndOptions = {
channelName: string;
ablyId?: string;
subscribeOnly?: boolean;
skip?: boolean;

onConnectionError?: (error: Ably.ErrorInfo) => unknown;
Expand Down
128 changes: 58 additions & 70 deletions src/platform/react-hooks/src/hooks/usePresence.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,83 +28,80 @@ describe('usePresence', () => {
otherClient = new FakeAblySdk().connectTo(channels);
});

it('presence data is not visible on first render as it runs in an effect', async () => {
renderInCtxProvider(ablyClient, <UsePresenceComponent></UsePresenceComponent>);

const values = screen.getByRole('presence').innerHTML;
expect(values).toBe('');

await act(async () => {
await wait(2);
// To let react run its updates so we don't see warnings in the test output
});
});
it('presence is entered after effect runs', async () => {
const enterListener = vi.fn();
ablyClient.channels.get(testChannelName).presence.subscribe(['enter'], enterListener);

it('presence data available after effect runs', async () => {
renderInCtxProvider(ablyClient, <UsePresenceComponent></UsePresenceComponent>);

await act(async () => {
await wait(2);
await waitFor(() => {
expect(enterListener).toHaveBeenCalledWith(expect.objectContaining({ data: 'bar' }));
});

const values = screen.getByRole('presence').innerHTML;
expect(values).toContain(`"bar"`);
});

it('presence data updates when update function is triggered', async () => {
const updateListener = vi.fn();
ablyClient.channels.get(testChannelName).presence.subscribe(['update'], updateListener);

renderInCtxProvider(ablyClient, <UsePresenceComponent></UsePresenceComponent>);

await act(async () => {
const button = screen.getByText(/Update/i);
button.click();
await wait(2);
});

const values = screen.getByRole('presence').innerHTML;
expect(values).toContain(`"baz"`);
});

it('presence data respects updates made by other clients', async () => {
renderInCtxProvider(ablyClient, <UsePresenceComponent></UsePresenceComponent>);

await act(async () => {
otherClient.channels.get(testChannelName).presence.enter('boop');
await waitFor(() => {
expect(updateListener).toHaveBeenCalledWith(expect.objectContaining({ data: 'baz' }));
});

const presenceElement = screen.getByRole('presence');
const values = presenceElement.innerHTML;
expect(presenceElement.children.length).toBe(2);
expect(values).toContain(`"bar"`);
expect(values).toContain(`"boop"`);
});

it('presence API works with type information provided', async () => {
renderInCtxProvider(
ablyClient,
<ChannelProvider channelName="testChannelName">
<TypedUsePresenceComponent></TypedUsePresenceComponent>
</ChannelProvider>,
);
const enterListener = vi.fn();
const updateListener = vi.fn();
ablyClient.channels.get(testChannelName).presence.subscribe('enter', enterListener);
ablyClient.channels.get(testChannelName).presence.subscribe('update', updateListener);

renderInCtxProvider(ablyClient, <TypedUsePresenceComponent></TypedUsePresenceComponent>);

// Wait for `usePresence` to be rendered and entered presence
await waitFor(() => {
expect(enterListener).toHaveBeenCalledWith(expect.objectContaining({ data: { foo: 'bar' } }));
});

await act(async () => {
const button = screen.getByText(/Update/i);
button.click();
await wait(2);
});

const values = screen.getByRole('presence').innerHTML;
expect(values).toContain(`"data":{"foo":"bar"}`);
// Wait for presence data be updated
await waitFor(() => {
expect(updateListener).toHaveBeenCalledWith(expect.objectContaining({ data: { foo: 'baz' } }));
});
});

it('skip param', async () => {
it('`skip` param prevents mounting and entering presence', async () => {
const enterListener = vi.fn();
ablyClient.channels.get(testChannelName).presence.subscribe('enter', enterListener);

renderInCtxProvider(ablyClient, <UsePresenceComponent skip={true}></UsePresenceComponent>);

// wait for component to be rendered
await act(async () => {
await wait(2);
});

const values = screen.getByRole('presence').innerHTML;
expect(values).to.not.contain(`"bar"`);
// expect presence not to be entered
await waitFor(() => {
expect(enterListener).not.toHaveBeenCalled();
});
});

it('usePresence works with multiple clients', async () => {
const updateListener = vi.fn();
ablyClient.channels.get(testChannelName).presence.subscribe('update', updateListener);

renderInCtxProvider(
ablyClient,
<AblyProvider ablyId="otherClient" client={otherClient as unknown as Ably.RealtimeClient}>
Expand All @@ -120,9 +117,10 @@ describe('usePresence', () => {
await wait(2);
});

const values = screen.getByRole('presence').innerHTML;
expect(values).toContain(`"data":"baz1"`);
expect(values).toContain(`"data":"baz2"`);
await waitFor(() => {
expect(updateListener).toHaveBeenCalledWith(expect.objectContaining({ data: 'baz1' }));
expect(updateListener).toHaveBeenCalledWith(expect.objectContaining({ data: 'baz2' }));
});
});

it('handles channel errors', async () => {
Expand Down Expand Up @@ -207,15 +205,7 @@ describe('usePresence', () => {
});

const UsePresenceComponent = ({ skip }: { skip?: boolean }) => {
const { presenceData, updateStatus } = usePresence({ channelName: testChannelName, skip }, 'bar');

const presentUsers = presenceData.map((presence, index) => {
return (
<li key={index}>
{presence.clientId} - {JSON.stringify(presence)}
</li>
);
});
const { updateStatus } = usePresence({ channelName: testChannelName, skip }, 'bar');

return (
<>
Expand All @@ -226,23 +216,14 @@ const UsePresenceComponent = ({ skip }: { skip?: boolean }) => {
>
Update
</button>
<ul role="presence">{presentUsers}</ul>
</>
);
};

const UsePresenceComponentMultipleClients = () => {
const { presenceData: val1, updateStatus: update1 } = usePresence({ channelName: testChannelName }, 'foo');
const { updateStatus: update1 } = usePresence({ channelName: testChannelName }, 'foo');
const { updateStatus: update2 } = usePresence({ channelName: testChannelName, ablyId: 'otherClient' }, 'bar');

const presentUsers = val1.map((presence, index) => {
return (
<li key={index}>
{presence.clientId} - {JSON.stringify(presence)}
</li>
);
});

return (
<>
<button
Expand All @@ -253,7 +234,6 @@ const UsePresenceComponentMultipleClients = () => {
>
Update
</button>
<ul role="presence">{presentUsers}</ul>
</>
);
};
Expand Down Expand Up @@ -286,11 +266,19 @@ interface MyPresenceType {
}

const TypedUsePresenceComponent = () => {
const { presenceData } = usePresence<MyPresenceType>('testChannelName', {
foo: 'bar',
});
const { updateStatus } = usePresence<MyPresenceType>(testChannelName, { foo: 'bar' });

return <div role="presence">{JSON.stringify(presenceData)}</div>;
return (
<div role="presence">
<button
onClick={() => {
updateStatus({ foo: 'baz' });
}}
>
Update
</button>
</div>
);
};

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
58 changes: 11 additions & 47 deletions src/platform/react-hooks/src/hooks/usePresence.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,45 @@
import type * as Ably from 'ably';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { ChannelParameters } from '../AblyReactHooks.js';
import { useAbly } from './useAbly.js';
import { useStateErrors } from './useStateErrors.js';
import { useChannelInstance } from './useChannelInstance.js';
import { useStateErrors } from './useStateErrors.js';

interface PresenceMessage<T = any> extends Ably.PresenceMessage {
data: T;
}

export interface PresenceResult<T> {
presenceData: PresenceMessage<T>[];
export interface PresenceEnterResult<T> {
updateStatus: (messageOrPresenceObject: T) => void;
connectionError: Ably.ErrorInfo | null;
channelError: Ably.ErrorInfo | null;
}

export type OnPresenceMessageReceived<T> = (presenceData: PresenceMessage<T>) => void;
export type UseStatePresenceUpdate = (presenceData: Ably.PresenceMessage[]) => void;

const INACTIVE_CONNECTION_STATES: Ably.ConnectionState[] = ['suspended', 'closing', 'closed', 'failed'];

export function usePresence<T = any>(
channelNameOrNameAndOptions: ChannelParameters,
messageOrPresenceObject?: T,
onPresenceUpdated?: OnPresenceMessageReceived<T>,
): PresenceResult<T> {
): PresenceEnterResult<T> {
const params =
typeof channelNameOrNameAndOptions === 'object'
? channelNameOrNameAndOptions
: { channelName: channelNameOrNameAndOptions };
const skip = params.skip;

const ably = useAbly(params.ablyId);
const { channel } = useChannelInstance(params.ablyId, params.channelName);

const subscribeOnly = typeof channelNameOrNameAndOptions === 'string' ? false : params.subscribeOnly;

const skip = params.skip;

const { connectionError, channelError } = useStateErrors(params);

const [presenceData, updatePresenceData] = useState<Array<PresenceMessage<T>>>([]);

const updatePresence = async (message?: Ably.PresenceMessage) => {
const snapshot = await channel.presence.get();
updatePresenceData(snapshot);

onPresenceUpdated?.call(this, message);
};

const onMount = async () => {
channel.presence.subscribe(['enter', 'leave', 'update'], updatePresence);

if (!subscribeOnly) {
await channel.presence.enter(messageOrPresenceObject);
}

const snapshot = await channel.presence.get();
updatePresenceData(snapshot);
await channel.presence.enter(messageOrPresenceObject);
};

const onUnmount = () => {
// if connection is in one of inactive states, leave call will produce exception
if (channel.state === 'attached' && !INACTIVE_CONNECTION_STATES.includes(ably.connection.state)) {
if (!subscribeOnly) {
channel.presence.leave();
}
channel.presence.leave();
}
channel.presence.unsubscribe(['enter', 'leave', 'update'], updatePresence);
};

const useEffectHook = () => {
!skip && onMount();
if (!skip) onMount();
return () => {
onUnmount();
};
Expand All @@ -82,14 +50,10 @@ export function usePresence<T = any>(

const updateStatus = useCallback(
(messageOrPresenceObject: T) => {
if (!subscribeOnly) {
channel.presence.update(messageOrPresenceObject);
} else {
throw new Error('updateStatus can not be called while using the hook in subscribeOnly mode');
}
channel.presence.update(messageOrPresenceObject);
},
[subscribeOnly, channel],
[channel],
);

return { presenceData, updateStatus, connectionError, channelError };
return { updateStatus, connectionError, channelError };
}
Loading

0 comments on commit 730c621

Please sign in to comment.