Skip to content

Commit

Permalink
feat: create / edit message plus subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
ttypic committed Dec 7, 2023
1 parent 3836cb5 commit 0742674
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 18 deletions.
57 changes: 54 additions & 3 deletions src/ChatApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
export interface CreateConversationOptions {
import { Conversation, Message } from './entities.js';

export interface CreateConversationRequest {
ttl: number;
}

export interface Conversation {
export interface CreateConversationResponse {
id: string;
}

export interface GetMessagesQueryParams {
startId?: string;
endId?: string;
direction?: 'forwards' | 'backwards';
limit: number;
}

export interface CreateMessageResponse {
id: string;
}

export interface UpdateMessageResponse {
id: string;
}

Expand All @@ -14,14 +31,48 @@ export class ChatApi {
}
async getConversation(conversationId: string): Promise<Conversation> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}`);
if (!response.ok) throw Error(response.statusText);
return response.json();
}

async createConversation(conversationId: string, body?: CreateConversationOptions): Promise<Conversation> {
async createConversation(
conversationId: string,
body?: CreateConversationRequest,
): Promise<CreateConversationResponse> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) throw Error(response.statusText);
return response.json();
}

async getMessages(conversationId: string, params: GetMessagesQueryParams): Promise<Message[]> {
const queryString = new URLSearchParams({
...params,
limit: params.limit.toString(),
}).toString();

const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}/messages?${queryString}`);
if (!response.ok) throw Error(response.statusText);
return response.json();
}

async sendMessage(conversationId: string, text: string): Promise<CreateMessageResponse> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}/messages`, {
method: 'POST',
body: JSON.stringify({ content: text }),
});
if (!response.ok) throw Error(response.statusText);
return response.json();
}

async editMessage(conversationId: string, messageId: string, text: string): Promise<UpdateMessageResponse> {
const response = await fetch(`${this.baseUrl}/v1/conversation/${conversationId}/messages/${messageId}`, {
method: 'POST',
body: JSON.stringify({ content: text }),
});
if (!response.ok) throw Error(response.statusText);
return response.json();
}
}
7 changes: 5 additions & 2 deletions src/Conversation.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { Realtime } from 'ably/promises';
import { Realtime, Types } from 'ably/promises';
import { ChatApi } from './ChatApi.js';
import { Messages } from './Messages.js';
import RealtimeChannelPromise = Types.RealtimeChannelPromise;

export class Conversation {
private readonly conversationId: string;
private readonly realtime: Realtime;
private readonly chatApi: ChatApi;
private readonly channel: RealtimeChannelPromise;
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);
this.channel = realtime.channels.get(`${conversationId}::$conversation`);
this.messages = new Messages(conversationId, this.channel, this.chatApi);
}

async create() {
Expand Down
102 changes: 89 additions & 13 deletions src/Messages.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,107 @@
import { Realtime } from 'ably/promises';
import { Types } from 'ably/promises';
import { ChatApi } from './ChatApi.js';
import { Message } from './entities.js';
import RealtimeChannelPromise = Types.RealtimeChannelPromise;
import { MessageEvents } from './events.js';

const enum Direction {
ascending = 'ascending',
descending = 'descending',
export const enum Direction {
forwards = 'forwards',
backwards = 'backwards',
}

interface QueryOptions {
from: string;
to: string;
limit: string;
direction: Direction;
startId?: string;
endId?: string;
limit: number;
direction?: keyof typeof Direction;
}

interface MessageListenerArgs {
type: MessageEvents;
message: Message;
}

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

export class Messages {
private readonly conversationId: string;
private readonly realtime: Realtime;
private readonly channel: RealtimeChannelPromise;
private readonly chatApi: ChatApi;
private messageToChannelListener = new WeakMap<MessageListener, ChannelListener>();

constructor(conversationId: string, realtime: Realtime, chatApi: ChatApi) {
constructor(conversationId: string, channel: RealtimeChannelPromise, chatApi: ChatApi) {
this.conversationId = conversationId;
this.realtime = realtime;
this.channel = channel;
this.chatApi = chatApi;
}

// eslint-disable-next-line
async query(options: QueryOptions) {
return [];
async query(options: QueryOptions): Promise<Message[]> {
return this.chatApi.getMessages(this.conversationId, options);
}

async send(text: string): Promise<Message> {
const createdMessages: Record<string, Message> = {};

let waitingMessageId: string | null = null;
let resolver: ((message: Message) => void) | null = null;

const waiter = ({ data }: Types.Message) => {
const message: Message = data;
if (waitingMessageId == null) createdMessages[message.id] = message;
if (waitingMessageId == message.id) resolver?.(message);
};

await this.channel.subscribe(MessageEvents.created, waiter);
const { id } = await this.chatApi.sendMessage(this.conversationId, text);

if (createdMessages[id]) return createdMessages[id];

waitingMessageId = id;

return new Promise((resolve) => {
resolver = (message) => {
this.channel.unsubscribe(MessageEvents.created, waiter);
resolve(message);
};
});
}

async edit(messageId: string, text: string): Promise<Message> {
let resolver: ((message: Message) => void) | null = null;
const waiter = ({ data }: Types.Message) => {
const message: Message = data;
if (messageId == message.id) resolver?.(message);
};

const promise: Promise<Message> = new Promise((resolve) => {
resolver = (message) => {
this.channel.unsubscribe(MessageEvents.updated, waiter);
resolve(message);
};
});

await this.channel.subscribe(MessageEvents.updated, waiter);
await this.chatApi.editMessage(this.conversationId, messageId, text);

return promise;
}

async subscribe(event: MessageEvents, listener: MessageListener) {
const channelListener = ({ name, data }: Types.Message) => {
listener({
type: name as MessageEvents,
message: data,
});
};
this.messageToChannelListener.set(listener, channelListener);
return this.channel.subscribe(event, channelListener);
}

unsubscribe(event: MessageEvents, listener: MessageListener) {
const channelListener = this.messageToChannelListener.get(listener);
if (!channelListener) return;
this.channel.unsubscribe(event, channelListener);
}
}
30 changes: 30 additions & 0 deletions src/entities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface Conversation {
id: string;
application_id: string;
ttl: number | null;
created_at: number;
}

export interface Message {
id: string;
client_id: string;
conversation_id: string;
content: string;
reactions: {
counts: Record<string, number>;
latest: Reaction[];
mine: Reaction[];
};
created_at: number;
updated_at: number | null;
deleted_at: number | null;
}

export interface Reaction {
id: string;
message_id: string;
type: string;
client_id: string;
updated_at: number | null;
deleted_at: number | null;
}
5 changes: 5 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const enum MessageEvents {
created = 'message.created',
updated = 'message.updated',
deleted = 'message.deleted',
}

0 comments on commit 0742674

Please sign in to comment.