Skip to content

Commit

Permalink
feat: add message reactions SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
ttypic committed Dec 18, 2023
1 parent 7544fc6 commit db318c0
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 9 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const reaction = await conversation.messages.addReaction(msgId, {
Delete reaction:

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

### Reaction object
Expand Down Expand Up @@ -292,8 +292,7 @@ conversation.reactions.add(reactionType)
Remove reaction

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

## Typing indicator
Expand Down
31 changes: 31 additions & 0 deletions src/ChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export interface UpdateMessageResponse {
id: string;
}

export interface AddReactionResponse {
id: string;
}

export interface DeleteReactionResponse {
id: string;
}

/**
* Chat SDK Backend
*/
Expand Down Expand Up @@ -109,4 +117,27 @@ export class ChatApi {
});
if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000);
}

async addMessageReaction(conversationId: string, messageId: string, type: string): Promise<AddReactionResponse> {
const response = await fetch(`${this.baseUrl}/v1/conversations/${conversationId}/messages/${messageId}/reactions`, {
method: 'POST',
headers: {
'ably-clientId': this.clientId,
},
body: JSON.stringify({ type }),
});
if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000);
return response.json();
}

async deleteMessageReaction(reactionId: string): Promise<DeleteReactionResponse> {
const response = await fetch(`${this.baseUrl}/v1/conversations/reactions/${reactionId}`, {
method: 'DELETE',
headers: {
'ably-clientId': this.clientId,
},
});
if (!response.ok) throw new ErrorInfo(response.statusText, response.status, 4000);
return response.json();
}
}
112 changes: 112 additions & 0 deletions src/Messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,116 @@ describe('Messages', () => {
});
});
});

describe('adding message reaction', () => {
it<TestContext>('should return reaction if chat backend request come before realtime', async (context) => {
const { chatApi, realtime } = context;
vi.spyOn(chatApi, 'addMessageReaction').mockResolvedValue({ id: 'reactionId' });

const conversation = new Conversation('conversationId', realtime, chatApi);
const reactionPromise = conversation.messages.addReaction('messageId', 'like');

context.emulateBackendPublish({
clientId: 'clientId',
data: {
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
},
});

const reaction = await reactionPromise;

expect(reaction).toContain({
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
});
});

it<TestContext>('should return reaction if chat backend request come after realtime', async (context) => {
const { chatApi, realtime } = context;

vi.spyOn(chatApi, 'addMessageReaction').mockImplementation(async (conversationId, messageId, type) => {
context.emulateBackendPublish({
clientId: 'clientId',
data: {
id: 'reactionId',
message_id: messageId,
type,
client_id: 'clientId',
},
});
return { id: 'reactionId' };
});

const conversation = new Conversation('conversationId', realtime, chatApi);
const reaction = await conversation.messages.addReaction('messageId', 'like');

expect(reaction).toContain({
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
});
});
});

describe('deleting message reaction', () => {
it<TestContext>('should return reaction if chat backend request come before realtime', async (context) => {
const { chatApi, realtime } = context;
vi.spyOn(chatApi, 'deleteMessageReaction').mockResolvedValue(undefined);

const conversation = new Conversation('conversationId', realtime, chatApi);
const reactionPromise = conversation.messages.removeReaction('reactionId');

context.emulateBackendPublish({
clientId: 'clientId',
data: {
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
},
});

const reaction = await reactionPromise;

expect(reaction).toContain({
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
});
});

it<TestContext>('should return reaction if chat backend request come after realtime', async (context) => {
const { chatApi, realtime } = context;

vi.spyOn(chatApi, 'deleteMessageReaction').mockImplementation(async (reactionId) => {
context.emulateBackendPublish({
clientId: 'clientId',
data: {
id: reactionId,
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
},
});
return { id: reactionId };
});

const conversation = new Conversation('conversationId', realtime, chatApi);
const reaction = await conversation.messages.removeReaction('reactionId');

expect(reaction).toContain({
id: 'reactionId',
message_id: 'messageId',
type: 'like',
client_id: 'clientId',
});
});
});
});
64 changes: 58 additions & 6 deletions src/Messages.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Types } from 'ably/promises';
import { ChatApi } from './ChatApi.js';
import { Message } from './entities.js';
import { Message, Reaction } from './entities.js';
import RealtimeChannelPromise = Types.RealtimeChannelPromise;
import { MessageEvents } from './events.js';
import { MessageEvents, ReactionEvents } from './events.js';

export const enum Direction {
forwards = 'forwards',
Expand Down Expand Up @@ -43,14 +43,14 @@ export class Messages {
}

async send(text: string): Promise<Message> {
return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.created, async () => {
return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.created, async () => {
const { id } = await this.chatApi.sendMessage(this.conversationId, text);
return id;
});
}

async edit(messageId: string, text: string): Promise<Message> {
return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => {
return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => {
await this.chatApi.editMessage(this.conversationId, messageId, text);
return messageId;
});
Expand All @@ -61,12 +61,26 @@ export class Messages {
async delete(messageIdOrMessage: string | Message): Promise<Message> {
const messageId = typeof messageIdOrMessage === 'string' ? messageIdOrMessage : messageIdOrMessage.id;

return this.makeApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => {
return this.makeMessageApiCallAndWaitForRealtimeResult(MessageEvents.deleted, async () => {
await this.chatApi.deleteMessage(this.conversationId, messageId);
return messageId;
});
}

async addReaction(messageId: string, reactionType: string) {
return this.makeReactionApiCallAndWaitForRealtimeResult(ReactionEvents.added, async () => {
const { id } = await this.chatApi.addMessageReaction(this.conversationId, messageId, reactionType);
return id;
});
}

async removeReaction(reactionId: string) {
return this.makeReactionApiCallAndWaitForRealtimeResult(ReactionEvents.deleted, async () => {
await this.chatApi.deleteMessageReaction(reactionId);
return reactionId;
});
}

async subscribe(event: MessageEvents, listener: MessageListener) {
const channelListener = ({ name, data }: Types.Message) => {
listener({
Expand All @@ -84,7 +98,7 @@ export class Messages {
this.channel.unsubscribe(event, channelListener);
}

private async makeApiCallAndWaitForRealtimeResult(event: MessageEvents, apiCall: () => Promise<string>) {
private async makeMessageApiCallAndWaitForRealtimeResult(event: MessageEvents, apiCall: () => Promise<string>) {
const queuedMessages: Record<string, Message> = {};

let waitingMessageId: string | null = null;
Expand Down Expand Up @@ -121,4 +135,42 @@ export class Messages {
};
});
}

private async makeReactionApiCallAndWaitForRealtimeResult(event: ReactionEvents, apiCall: () => Promise<string>) {
const queuedReaction: Record<string, Reaction> = {};

let waitingReactionId: string | null = null;
let resolver: ((reaction: Reaction) => void) | null = null;

const waiter = ({ data }: Types.Message) => {
const reaction: Reaction = data;
if (waitingReactionId === null) {
queuedReaction[reaction.id] = reaction;
} else if (waitingReactionId === reaction.id) {
resolver?.(reaction);
resolver = null;
}
};

await this.channel.subscribe(event, waiter);

try {
const reactionId = await apiCall();
if (queuedReaction[reactionId]) {
this.channel.unsubscribe(event, waiter);
return queuedReaction[reactionId];
}
waitingReactionId = reactionId;
} catch (e) {
this.channel.unsubscribe(event, waiter);
throw e;
}

return new Promise<Reaction>((resolve) => {
resolver = (reaction) => {
this.channel.unsubscribe(event, waiter);
resolve(reaction);
};
});
}
}
5 changes: 5 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export const enum MessageEvents {
updated = 'message.updated',
deleted = 'message.deleted',
}

export const enum ReactionEvents {
added = 'reaction.added',
deleted = 'reaction.deleted',
}

0 comments on commit db318c0

Please sign in to comment.