Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CON-97] feat: add reaction methods #26

Merged
merged 6 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 3 additions & 4 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 @@ -169,7 +169,7 @@ conversation.messages.subscribe(({ type, message }) => {

```ts
// Subscribe to all reactions
conversation.reactions.subscribe(({ type, reaction }) => {
conversation.messages.subscribeReactions(({ type, reaction }) => {
switch (type) {
case 'reaction.added':
console.log(reaction);
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just to check my understanding, would this be the "room-level" reactions, whereas the message-level ones would be the example above?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's right

```

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

export interface AddReactionResponse {
id: string;
}

/**
* Chat SDK Backend
*/
Expand Down Expand Up @@ -109,4 +113,26 @@ 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);
Copy link
Collaborator

@AndyTWF AndyTWF Jan 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps another branch to change this as aware this is in other places too, but if the response is an ErrorInfo type, all the information we need to create an ErrorInfo will be in the response :)

return response.json();
}

async deleteMessageReaction(reactionId: string): Promise<void> {
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);
}
}
111 changes: 111 additions & 0 deletions src/Messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,115 @@ 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',
},
});
});

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',
});
});
});
});
93 changes: 84 additions & 9 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 All @@ -21,15 +21,21 @@ interface MessageListenerArgs {
message: Message;
}

interface ReactionListenerArgs {
type: ReactionEvents;
reaction: Reaction;
}

export type MessageListener = (args: MessageListenerArgs) => void;
export type ReactionListener = (args: ReactionListenerArgs) => void;
type ChannelListener = Types.messageCallback<Types.Message>;

export class Messages {
private readonly conversationId: string;
private readonly channel: RealtimeChannelPromise;
private readonly chatApi: ChatApi;

private messageToChannelListener = new WeakMap<MessageListener, ChannelListener>();
private channelListeners = new WeakMap<MessageListener | ReactionListener, ChannelListener>();

constructor(conversationId: string, channel: RealtimeChannelPromise, chatApi: ChatApi) {
this.conversationId = conversationId;
Expand All @@ -43,14 +49,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,30 +67,61 @@ 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({
type: name as MessageEvents,
message: data,
});
};
this.messageToChannelListener.set(listener, channelListener);
this.channelListeners.set(listener, channelListener);
return this.channel.subscribe(event, channelListener);
}

unsubscribe(event: MessageEvents, listener: MessageListener) {
const channelListener = this.messageToChannelListener.get(listener);
const channelListener = this.channelListeners.get(listener);
if (!channelListener) return;
this.channel.unsubscribe(event, channelListener);
}

private async makeApiCallAndWaitForRealtimeResult(event: MessageEvents, apiCall: () => Promise<string>) {
async subscribeReactions(event: ReactionEvents, listener: ReactionListener) {
const channelListener = ({ name, data }: Types.Message) => {
listener({
type: name as ReactionEvents,
reaction: data,
});
};
this.channelListeners.set(listener, channelListener);
return this.channel.subscribe(event, channelListener);
}

unsubscribeReactions(event: ReactionEvents, listener: ReactionListener) {
const channelListener = this.channelListeners.get(listener);
if (!channelListener) return;
this.channel.unsubscribe(event, channelListener);
}

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

let waitingMessageId: string | null = null;
Expand Down Expand Up @@ -121,4 +158,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',
}
Loading