Skip to content

Commit

Permalink
feat: create conversation SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
ttypic committed Dec 6, 2023
1 parent 3faca8a commit 33e14d8
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 64 deletions.
111 changes: 50 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Ably Chat SDK

The **Chat SDK** offers a seamless and customizable API designed to facilitate diverse
in-app conversations scenarios, encompassing live comments, in-app chat functionalities,
The **Chat SDK** offers a seamless and customizable API designed to facilitate diverse
in-app conversations scenarios, encompassing live comments, in-app chat functionalities,
and the management of real-time updates and user interactions.

## Prerequisites
Expand Down Expand Up @@ -37,34 +37,28 @@ You can use [basic authentication](https://ably.com/docs/auth/basic) i.e. the AP
To use Chat you must also set a [`clientId`](https://ably.com/docs/auth/identified-clients) so that clients are identifiable. If you are prototyping, you can use a package like [nanoid](https://www.npmjs.com/package/nanoid) to generate an ID.


## Creating a new Room
## Getting Conversation controller

A Room is a chat between one or more participants that may be backed by one or more Ably PubSub channels.
You can get conversation controller:

```ts
const room = await client.rooms.create(`namespace:${entityId}`);
const conversation = client.conversations.get(conversationId);
```

## Getting existing Room
## Create a Conversation

You can connect to the existing room by its name:
You can create conversation using controller:

```ts
const room = await client.rooms.get(`namespace:${entityId}`);
```

Also you can send `createIfNotExists: true` option that will create new Room if it doesn't exist.

```ts
const room = await client.rooms.get(`namespace:${entityId}`, { createIfNotExists: true });
await conversation.create({ ttl });
```

## Messaging

Get window of messages:

```ts
const messages = await room.messages.query({
const messages = await conversation.messages.query({
limit,
from,
to,
Expand All @@ -75,24 +69,24 @@ const messages = await room.messages.query({
Send messages:

```ts
const message = await room.messages.send({
const message = await conversation.messages.send({
text
})
```

Update message:

```ts
const message = await room.messages.edit(msgId, {
const message = await conversation.messages.edit(msgId, {
text
})
```

Delete message:

```ts
await room.messages.delete(msgId)
await room.messages.delete(msg)
await conversation.messages.delete(msgId)
await conversation.messages.delete(msg)
```

### Message Object
Expand All @@ -113,7 +107,7 @@ await room.messages.delete(msg)
],
"mine": [
// List of Reaction objects
]
]
},
"created_at": "number",
"updated_at": "number|null",
Expand All @@ -127,7 +121,7 @@ await room.messages.delete(msg)
Add reaction:

```ts
const reaction = await room.messages.addReaction(msgId, {
const reaction = await conversation.messages.addReaction(msgId, {
type,
...
})
Expand All @@ -136,7 +130,7 @@ const reaction = await room.messages.addReaction(msgId, {
Delete reaction:

```ts
await room.messages.removeReaction(msgId, type)
await conversation.messages.removeReaction(msgId, type)
```

### Reaction object
Expand All @@ -155,8 +149,8 @@ await room.messages.removeReaction(msgId, type)
### Subscribe to message changes

```ts
// Subscribe to all message events in a room
room.messages.subscribe(({ type, message }) => {
// Subscribe to all message events in a conversation
conversation.messages.subscribe(({ type, message }) => {
switch (type) {
case 'message.created':
console.log(message);
Expand All @@ -175,7 +169,7 @@ room.messages.subscribe(({ type, message }) => {

```ts
// Subscribe to all reactions
room.reactions.subscribe(({ type, reaction }) => {
conversation.reactions.subscribe(({ type, reaction }) => {
switch (type) {
case 'reaction.added':
console.log(reaction);
Expand All @@ -188,8 +182,8 @@ room.reactions.subscribe(({ type, reaction }) => {
```

```ts
// Subscribe to specific even in a room
room.messages.subscribe('message.created', ({ type, message }) => {
// Subscribe to specific even in a conversation
conversation.messages.subscribe('message.created', ({ type, message }) => {
console.log(message);
});
```
Expand All @@ -200,10 +194,10 @@ Common use-case for Messages is getting latest messages and subscribe to future
you can use `fetch` option:

```ts
room.messages.subscribe(({ type, message, ...restEventsPayload }) => {
conversation.messages.subscribe(({ type, message, ...restEventsPayload }) => {
switch (type) {
case 'message.created':
// last messages will come as message.created event
// last messages will come as message.created event
console.log(message);
break;
default:
Expand All @@ -220,71 +214,66 @@ room.messages.subscribe(({ type, message, ...restEventsPayload }) => {

## Presence

> [!IMPORTANT]
> Idea is to keep it similar to Spaces members and potentially reuse code
> [!IMPORTANT]
> Idea is to keep it similar to Spaces members and potentially reuse code
```ts
// Enter a room, publishing an update event, including optional profile data
await room.enter({
// Enter a conversation, publishing an update event, including optional profile data
await conversation.enter({
username: 'Claire Lemons',
avatar: 'https://slides-internal.com/users/clemons.png',
});
```

```ts
// Subscribe to all member events in a room
room.members.subscribe((memberUpdate) => {
// Subscribe to all member events in a conversation
conversation.members.subscribe((memberUpdate) => {
console.log(memberUpdate);
});

// Subscribe to member enter events only
room.members.subscribe('enter', (memberJoined) => {
conversation.members.subscribe('enter', (memberJoined) => {
console.log(memberJoined);
});

// Subscribe to member leave events only
room.members.subscribe('leave', (memberLeft) => {
conversation.members.subscribe('leave', (memberLeft) => {
console.log(memberLeft);
});

// Subscribe to member remove events only
room.members.subscribe('remove', (memberRemoved) => {
// Subscribe to member update events only
conversation.members.subscribe('update', (memberRemoved) => {
console.log(memberRemoved);
});

// Subscribe to profile updates on members only
room.members.subscribe('updateProfile', (memberProfileUpdated) => {
console.log(memberProfileUpdated);
});
```

### Getting a snapshot of members

Members has methods to get the current snapshot of member state:

```ts
// Get all members in a room
const allMembers = await room.members.getAll();
// Get all members in a conversation
const allMembers = await conversation.members.getAll();

// Get your own member object
const myMemberInfo = await room.members.getSelf();
const myMemberInfo = await conversation.members.getSelf();

// Get everyone else's member object but yourself
const othersMemberInfo = await room.members.getOthers();
const othersMemberInfo = await conversation.members.getOthers();
```

## Room reactions
## Conversation reactions

Get reactions

```ts
room.reactions.get()
conversation.reactions.get()
```

Subscribe to reactions updates

```ts
room.reactions.subscribe(({ type, reaction }) => {
conversation.reactions.subscribe(({ type, reaction }) => {
switch (type) {
case "reaction.added":
case "reaction.deleted":
Expand All @@ -297,34 +286,34 @@ room.reactions.subscribe(({ type, reaction }) => {
Add reaction

```ts
room.reactions.add(reactionType)
conversation.reactions.add(reactionType)
```

Remove reaction

```ts
room.reactions.delete(reaction)
room.reactions.delete(reactionType)
conversation.reactions.delete(reaction)
conversation.reactions.delete(reactionType)
```

## Typing indicator

This function should be invoked on each keypress on the input field

```ts
room.typing.type()
conversation.typing.type()
```

This function should be triggered when the user exits the input field focus.

```ts
room.typing.stop()
conversation.typing.stop()
```

Subscribe to typing events:

```ts
room.messages.subscribe(({ type, member }) => {
conversation.messages.subscribe(({ type, member }) => {
switch (type) {
case 'typings.typed':
case 'typings.stopped':
Expand All @@ -337,19 +326,19 @@ room.messages.subscribe(({ type, member }) => {
## Connection and Ably channels statuses

Conversation exposes `channel` and `connection` fields, which implements `EventEmitter` interface,
you can register a channel and connection state change listener with the on() or once() methods,
you can register a channel and connection state change listener with the on() or once() methods,
depending on whether you want to monitor all state changes, or only the first occurrence of one.

```ts
room.connection.on('connected', (stateChange) => {
conversation.connection.on('connected', (stateChange) => {
console.log('Ably is connected');
});

room.connection.on((stateChange) => {
conversation.connection.on((stateChange) => {
console.log('New connection state is ' + stateChange.current);
});

room.channel.on('attached', (stateChange) => {
conversation.channel.on('attached', (stateChange) => {
console.log('channel ' + channel.name + ' is now attached');
});
```
17 changes: 14 additions & 3 deletions src/Chat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { Realtime } from 'ably/promises';

Check failure on line 1 in src/Chat.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
import { Conversations } from './Conversations';

Check failure on line 2 in src/Chat.ts

View workflow job for this annotation

GitHub Actions / lint

Missing file extension for "./Conversations"

const DEFAULT_BASE_URL =
process.env.NODE_ENV === 'production' ? 'https://rest.ably.io/conversation' : 'http://localhost:8281/conversations';

export class Chat {
private ably: Realtime;
constructor(ably: Realtime) {
this.ably = ably;
private readonly realtime: Realtime;

readonly conversations: Conversations;
constructor(realtime: Realtime, baseUrl = DEFAULT_BASE_URL) {
this.realtime = realtime;
this.conversations = new Conversations(realtime, baseUrl);
}

get connection() {
return this.realtime.connection;
}
}
27 changes: 27 additions & 0 deletions src/ChatApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface CreateConversationOptions {
ttl: number;
}

export interface Conversation {
id: string;
}

export class ChatApi {
private readonly baseUrl: string;

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async getConversation(conversationId: string): Promise<Conversation> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}`);
return response.json();
}

async createConversation(conversationId: string, body?: CreateConversationOptions): Promise<Conversation> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
}
21 changes: 21 additions & 0 deletions src/Conversation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Realtime } from 'ably/promises';
import { ChatApi } from './ChatApi';

Check failure on line 2 in src/Conversation.ts

View workflow job for this annotation

GitHub Actions / lint

Missing file extension for "./ChatApi"
import { Messages } from './Messages';

Check failure on line 3 in src/Conversation.ts

View workflow job for this annotation

GitHub Actions / lint

Missing file extension for "./Messages"

export class Conversation {
private readonly conversationId: string;
private readonly realtime: Realtime;
private readonly chatApi: ChatApi;
readonly messages: Messages;

constructor(conversationId: string, realtime: Realtime, chatApi: ChatApi) {
this.conversationId = conversationId;
this.realtime = realtime;
this.chatApi = chatApi;
this.messages = new Messages(conversationId, realtime, this.chatApi);
}

async create() {
await this.chatApi.createConversation(this.conversationId);
}
}
17 changes: 17 additions & 0 deletions src/Conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Realtime } from 'ably/promises';
import { ChatApi } from './ChatApi';

Check failure on line 2 in src/Conversations.ts

View workflow job for this annotation

GitHub Actions / lint

Missing file extension for "./ChatApi"
import { Conversation } from './Conversation';

Check failure on line 3 in src/Conversations.ts

View workflow job for this annotation

GitHub Actions / lint

Missing file extension for "./Conversation"

export class Conversations {
private readonly realtime: Realtime;
private readonly chatApi: ChatApi;

constructor(realtime: Realtime, baseUrl: string) {
this.realtime = realtime;
this.chatApi = new ChatApi(baseUrl);
}

get(conversationId: string) {
return new Conversation(conversationId, this.realtime, this.chatApi);
}
}
Loading

0 comments on commit 33e14d8

Please sign in to comment.