Skip to content
This repository has been archived by the owner on Oct 30, 2023. It is now read-only.

feat: add useChannelStateListener and useConnectionStateListener hooks #52

Closed
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,5 @@ dist

# TernJS port file
.tern-port

.DS_Store
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,44 @@ interface MyPresenceType {

`PresenceData` is a good way to store synchronised, per-client metadata, so types here are especially valuable.

### useConnectionStateListener

The `useConnectionStateListener` hook lets you attach a listener to be notified of [connection state changes](https://ably.com/docs/connect/states?lang=javascript). This can be useful for detecting when the client has lost connection.

```javascript
useConnectionStateListener((stateChange) => {
console.log(stateChange.current) // the new connection state
console.log(stateChange.previous) // the previous connection state
console.log(stateChange.reason) // if applicable, an error indicating the reason for the connection state change
})
```

You can also pass in a filter to only listen to a set of connection states:

```javascript
useConnectionStateListener('failed', listener); // the listener only gets called when the connection state becomes failed
useConnectionStateListener(['failed', 'suspended'], listener); // the listener only gets called when the connection state becomes failed or suspended
```

### useChannelStateListener

The `useChannelStateListener` hook lets you attach a listener to be notified of [channel state changes](https://ably.com/docs/channels?lang=javascript#states). This can be useful for detecting when a channel error has occured.

```javascript
useChannelStateListener((stateChange) => {
console.log(stateChange.current) // the new channel state
console.log(stateChange.previous) // the previous channel state
console.log(stateChange.reason) // if applicable, an error indicating the reason for the channel state change
})
```

You can also pass in a filter to only listen to a set of channel states:

```javascript
useChannelStateListener('failed', listener); // the listener only gets called when the channel state becomes failed
useChannelStateListener(['failed', 'suspended'], listener); // the listener only gets called when the channel state becomes failed or suspended
```

### useAbly

The useAbly hook lets you access the Ably client used by the AblyProvider context. This can be useful if you need to access ably-js APIs which aren't available through our react-hooks library.
Expand Down
45 changes: 44 additions & 1 deletion sample-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Types } from 'ably';
import React, { useState } from 'react';
import { useChannel, usePresence } from '../../src/index';
import {
AblyProvider,
useChannel,
usePresence,
useConnectionStateListener,
useChannelStateListener,
} from '../../src/index';
import './App.css';

function App() {
Expand All @@ -17,6 +23,24 @@ function App() {
}
);

const [connectionState, setConnectionState] = useState(ably.connection.state);

useConnectionStateListener((stateChange) => {
setConnectionState(stateChange.current)
});

const [ablyErr, setAblyErr] = useState('');
const [channelState, setChannelState] = useState(channel.state);
const [previousChannelState, setPreviousChannelState] = useState<undefined | Types.ChannelState>();
const [channelStateReason, setChannelStateReason] = useState<undefined | Types.ErrorInfo>();

useChannelStateListener('your-channel-name', 'detached', (stateChange) => {
setAblyErr(JSON.stringify(stateChange.reason));
setChannelState(stateChange.current);
setPreviousChannelState(stateChange.previous);
setChannelStateReason(stateChange.reason ?? undefined);
});

const messagePreviews = messages.map((msg, index) => (
<li key={index}>{msg.data.text}</li>
));
Expand Down Expand Up @@ -48,6 +72,25 @@ function App() {
</button>
</div>

<h2>Connection State</h2>
<div>{connectionState}</div>

<h2>Channel detach</h2>
<button onClick={() => channel.detach()}>Detach</button>
<button onClick={() => ably.close()}>Close</button>

<h2>Channel State</h2>
<h3>Current</h3>
<div>{channelState}</div>
<h3>Previous</h3>
<div>{previousChannelState}</div>
<h3>Reason</h3>
<div>{JSON.stringify(channelStateReason)}</div>

<h2>Ably error</h2>
<h3>Reason</h3>
<div>{ablyErr}</div>

<h2>Messages</h2>
<ul>{messagePreviews}</ul>

Expand Down
1 change: 1 addition & 0 deletions src/AblyReactHooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Ably from 'ably';
import { Types } from 'ably';

export type ChannelNameAndOptions = {
Expand Down
90 changes: 85 additions & 5 deletions src/fakes/ably.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { Types } from 'ably';
export class FakeAblySdk {
public clientId: string;
public channels: ClientChannelsCollection;
public connection: Connection;

constructor() {
this.clientId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
this.connection = new Connection();
}

public connectTo(channels: FakeAblyChannels) {
Expand All @@ -16,31 +18,109 @@ export class FakeAblySdk {
}
}

type EventListener = (...args: any[]) => unknown;

class EventEmitter {
listeners: Map<string | undefined, EventListener[]>;
allEvtListeners: EventListener[];
constructor() {
this.listeners = new Map<string | undefined, EventListener[]>();
this.allEvtListeners = [];
}

on(
eventOrListener: string | string[] | EventListener,
listener?: EventListener
) {
if (eventOrListener && listener) {
const listenerArr = this.listeners.get(eventOrListener as string);
if (listenerArr) {
listenerArr.push(listener);
return;
}
this.listeners.set(eventOrListener as string, [listener]);
} else {
this.allEvtListeners.push(eventOrListener as EventListener);
}
}

off(
eventOrListener: string | string[] | EventListener,
listener?: EventListener
) {
if (eventOrListener && listener) {
const listenerArr = this.listeners.get(eventOrListener as string);
if (listenerArr) {
this.listeners.set(
eventOrListener as string,
listenerArr.filter((x) => x !== listener)
);
return;
}
} else {
this.allEvtListeners = this.allEvtListeners.filter(
(x) => x !== listener
);
}
}

emit(event: string, ...args: any[]) {
const listenerArr = this.listeners.get(event);
const allListeners: EventListener[] = [];
if (listenerArr) {
allListeners.push(...listenerArr);
}
allListeners.push(...this.allEvtListeners);
allListeners.forEach((listener) => {
listener(...args);
});
}
}

class Connection extends EventEmitter {
state: Types.ConnectionState;

constructor() {
super();
this.state = 'initialized';
}
}

export class ClientChannelsCollection {
private client: FakeAblySdk;
private channels: FakeAblyChannels;
private _channelConnections: Map<string, ClientSingleChannelConnection>;

constructor(client: FakeAblySdk, channels: FakeAblyChannels) {
this.client = client;
this.channels = channels;
this._channelConnections = new Map();
}

public get(name: string): ClientSingleChannelConnection {
return new ClientSingleChannelConnection(
this.client,
this.channels.get(name)
);
let channelConnection = this._channelConnections.get(name);
if (channelConnection) {
return channelConnection;
} else {
channelConnection = new ClientSingleChannelConnection(
this.client,
this.channels.get(name)
);
this._channelConnections.set(name, channelConnection);
return channelConnection;
}
}
}

export class ClientSingleChannelConnection {
export class ClientSingleChannelConnection extends EventEmitter {
private client: FakeAblySdk;
private channel: Channel;

public presence: any;
public state: string;

constructor(client: FakeAblySdk, channel: Channel) {
super();
this.client = client;
this.channel = channel;
this.presence = new ClientPresenceConnection(
Expand Down
133 changes: 133 additions & 0 deletions src/hooks/useChannelStateListener.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React from 'react';
import { it, beforeEach, describe, expect } from 'vitest';
import { useChannelStateListener } from './useChannelStateListener';
import { useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { FakeAblySdk, FakeAblyChannels } from '../fakes/ably';
import { Types } from 'ably';
import { act } from 'react-dom/test-utils';
import { AblyProvider } from '../AblyProvider';

function renderInCtxProvider(
client: FakeAblySdk,
children: React.ReactNode | React.ReactNode[]
) {
return render(
<AblyProvider client={client as unknown as Types.RealtimePromise}>
{children}
</AblyProvider>
);
}

describe('useChannel', () => {
let channels: FakeAblyChannels;
let ablyClient: FakeAblySdk;

beforeEach(() => {
channels = new FakeAblyChannels(['blah']);
ablyClient = new FakeAblySdk().connectTo(channels);
});

it('can register a channel state listener for all state changes', async () => {
renderInCtxProvider(
ablyClient,
<UseChannelStateListenerComponent></UseChannelStateListenerComponent>
);

act(() => {
ablyClient.channels
.get('blah')
.emit('attached', { current: 'attached' });
});

expect(screen.getAllByRole('channelState')[0].innerHTML).toEqual(
'attached'
);
});

it('can register a channel state listener for named state changes', async () => {
renderInCtxProvider(
ablyClient,
<UseChannelStateListenerComponentNamedEvents
event={'failed'}
></UseChannelStateListenerComponentNamedEvents>
);

act(() => {
ablyClient.channels
.get('blah')
.emit('attached', { current: 'attached' });
});

expect(screen.getAllByRole('channelState')[0].innerHTML).toEqual(
'initialized'
);

act(() => {
ablyClient.channels
.get('blah')
.emit('failed', { current: 'failed' });
});

expect(screen.getAllByRole('channelState')[0].innerHTML).toEqual(
'failed'
);
});

it('can register a channel state listener for an array of named state changes', async () => {
renderInCtxProvider(
ablyClient,
<UseChannelStateListenerComponentNamedEvents
event={['failed', 'suspended']}
></UseChannelStateListenerComponentNamedEvents>
);

act(() => {
ablyClient.channels
.get('blah')
.emit('attached', { current: 'attached' });
});

expect(screen.getAllByRole('channelState')[0].innerHTML).toEqual(
'initialized'
);

act(() => {
ablyClient.channels
.get('blah')
.emit('suspended', { current: 'suspended' });
});

expect(screen.getAllByRole('channelState')[0].innerHTML).toEqual(
'suspended'
);
});
});

const UseChannelStateListenerComponent = () => {
const [channelState, setChannelState] =
useState<Types.ChannelState>('initialized');

useChannelStateListener('blah', (stateChange) => {
setChannelState(stateChange.current);
});

return <p role="channelState">{channelState}</p>;
};

interface UseChannelStateListenerComponentNamedEventsProps {
event: Types.ChannelState | Types.ChannelState[];
}

const UseChannelStateListenerComponentNamedEvents = ({
event,
}: UseChannelStateListenerComponentNamedEventsProps) => {
const [channelState, setChannelState] =
useState<Types.ChannelState>('initialized');

useChannelStateListener('blah', event, (stateChange) => {
setChannelState(stateChange.current);
});

return <p role="channelState">{channelState}</p>;
};
Loading