Skip to content

Commit

Permalink
Materialization: Add message deletion functionality to chat API and R…
Browse files Browse the repository at this point in the history
…eact hooks

- This commit introduces the ability to `soft` delete messages in the chat API with `DeleteMessageParams`.
It exposes this through the Messages feature as a new `delete` method.
- Updated related testing and documentation.
- Some minor improvements to messages.test.ts for clarity.

- The useMessage hook now also supports this functionality via a `deleteMessage` method returned from the hook.
- Updated related testing and documentation.

- The demo app has been updated to show the new deletion mechanic.
It uses the `react-icons` to display a bin icon, clicking this will delete the highlighted message.
  • Loading branch information
splindsay-92 committed Nov 7, 2024
1 parent 7eb916f commit 4c1e1b7
Show file tree
Hide file tree
Showing 17 changed files with 661 additions and 40 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ const message = await room.messages.send({
});
```

### Deleting messages

To delete a message, call `delete` on the `room.messages` property, with the original message you want to delete.

You can supply optional parameters to the `delete` method to provide additional context for the deletion.

These additional parameters are:
* `description`: a string that can be used to inform others as to why the message was deleted.
* `metadata`: a map of extra information that can be attached to the deletion message.

The return of this call will be the deleted message, as it would appear to other subscribers of the room.
This is a _soft delete_ and the message will still be available in the history, but with the `deletedAt` property set.

```ts
const deletedMessage = await room.messages.delete(message, { description: 'This message was deleted for ...' });
```

### Subscribing to incoming messages

To subscribe to incoming messages, call `subscribe` with your listener.
Expand Down
12 changes: 11 additions & 1 deletion demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"clsx": "^2.1.1",
"nanoid": "^5.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-icons": "^5.3.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
Expand Down
33 changes: 30 additions & 3 deletions demo/src/components/MessageComponent/MessageComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Message } from '@ably/chat';
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import clsx from 'clsx';
import { FaTrash } from 'react-icons/fa6';

function twoDigits(input: number): string {
if (input === 0) {
Expand All @@ -18,13 +19,23 @@ interface MessageProps {
message: Message;

onMessageClick?(id: string): void;

onMessageDelete?(msg: Message): void;
}

export const MessageComponent: React.FC<MessageProps> = ({ id, self = false, message, onMessageClick }) => {
export const MessageComponent: React.FC<MessageProps> = ({
id,
self = false,
message,
onMessageClick,
onMessageDelete,
}) => {
const handleMessageClick = useCallback(() => {
onMessageClick?.(id);
}, [id, onMessageClick]);

const [hovered, setHovered] = useState(false);

let displayCreatedAt: string;
if (Date.now() - message.createdAt.getTime() < 1000 * 60 * 60 * 24) {
// last 24h show the time
Expand All @@ -43,14 +54,21 @@ export const MessageComponent: React.FC<MessageProps> = ({ id, self = false, mes
twoDigits(message.createdAt.getMinutes());
}

const handleDelete = useCallback(() => {
// Add your delete handling logic here
onMessageDelete?.(message);
}, [message, onMessageDelete]);

return (
<div
className="chat-message"
onClick={handleMessageClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className={clsx('flex items-end', { ['justify-end']: self, ['justify-start']: !self })}>
<div
className={clsx('flex flex-col text max-w-xs mx-2', {
className={clsx('flex flex-col text max-w-xs mx-2 relative', {
['items-end order-1']: self,
['items-start order-2']: !self,
})}
Expand All @@ -69,6 +87,15 @@ export const MessageComponent: React.FC<MessageProps> = ({ id, self = false, mes
})}
>
{message.text}
{hovered && (
<FaTrash
className="ml-2 cursor-pointer text-red-500"
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
/>
)}
</div>
</div>
</div>
Expand Down
36 changes: 32 additions & 4 deletions demo/src/containers/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MessageInput } from '../../components/MessageInput';
import { useChatClient, useChatConnection, useMessages, useRoomReactions, useTyping } from '@ably/chat/react';
import { ReactionInput } from '../../components/ReactionInput';
import { ConnectionStatusComponent } from '../../components/ConnectionStatusComponent/ConnectionStatusComponent.tsx';
import { ConnectionStatus, Message, MessageEventPayload, PaginatedResult, Reaction } from '@ably/chat';
import { ConnectionStatus, Message, MessageEventPayload, MessageEvents, PaginatedResult, Reaction } from '@ably/chat';

export const Chat = () => {
const chatClient = useChatClient();
Expand All @@ -15,9 +15,26 @@ export const Chat = () => {

const isConnected: boolean = currentStatus === ConnectionStatus.Connected;

const { send: sendMessage, getPreviousMessages } = useMessages({
const {
send: sendMessage,
getPreviousMessages,
deleteMessage,
} = useMessages({
listener: (message: MessageEventPayload) => {
setMessages((prevMessage) => [...prevMessage, message.message]);
switch (message.type) {
case MessageEvents.Created:
setMessages((prevMessage) => [...prevMessage, message.message]);
break;
case MessageEvents.Deleted:
setMessages((prevMessage) => {
return prevMessage.filter((m) => {
return m.timeserial !== message.message.timeserial;
});
});
break;
default:
console.error('Unknown message', message);
}
},
onDiscontinuity: (discontinuity) => {
console.log('Discontinuity', discontinuity);
Expand Down Expand Up @@ -47,7 +64,9 @@ export const Chat = () => {
if (getPreviousMessages && loading) {
getPreviousMessages({ limit: 50 })
.then((result: PaginatedResult<Message>) => {
setMessages(result.items.reverse());
// reverse the messages so they are displayed in the correct order
// and don't include deleted messages
setMessages(result.items.filter((m) => !m.isDeleted).reverse());
setLoading(false);
})
.catch((error: unknown) => {
Expand Down Expand Up @@ -144,6 +163,15 @@ export const Chat = () => {
key={msg.timeserial}
self={msg.clientId === clientId}
message={msg}
onMessageDelete={(msg) => {
deleteMessage(msg, { description: 'deleted by user' }).then((deletedMessage: Message) => {
setMessages((prevMessages) => {
return prevMessages.filter((m) => {
return m.timeserial !== deletedMessage.timeserial;
});
});
});
}}
></MessageComponent>
))}
<div ref={messagesEndRef} />
Expand Down
41 changes: 38 additions & 3 deletions src/core/chat-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Ably from 'ably';

import { Logger } from './logger.js';
import { DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js';
import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js';
import { OccupancyEvent } from './occupancy.js';
import { PaginatedResult } from './query.js';

Expand Down Expand Up @@ -31,6 +31,23 @@ interface SendMessageParams {
headers?: MessageHeaders;
}

interface DeleteMessageParams {
description?: string;
metadata?: MessageActionMetadata;
}

export interface DeleteMessageResponse {
/**
* The serial of the deletion action.
*/
serial: string;

/**
* The timestamp of the deletion action.
*/
deletedAt: number;
}

/**
* Chat SDK Backend
*/
Expand Down Expand Up @@ -68,6 +85,23 @@ export class ChatApi {
});
}

async deleteMessage(
roomId: string,
timeserial: string,
params?: DeleteMessageParams,
): Promise<DeleteMessageResponse> {
const body: { description?: string; metadata?: MessageActionMetadata } = {
description: params?.description,
metadata: params?.metadata,
};
return this._makeAuthorizedRequest<DeleteMessageResponse>(
`/chat/v2/rooms/${roomId}/messages/${timeserial}/delete`,
'POST',
body,
{},
);
}

async sendMessage(roomId: string, params: SendMessageParams): Promise<CreateMessageResponse> {
const body: {
text: string;
Expand All @@ -90,10 +124,11 @@ export class ChatApi {

private async _makeAuthorizedRequest<RES = undefined>(
url: string,
method: 'POST' | 'GET' | ' PUT' | 'DELETE' | 'PATCH',
method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH',
body?: unknown,
params?: unknown,
): Promise<RES> {
const response = await this._realtime.request<RES>(method, url, this._apiProtocolVersion, {}, body);
const response = await this._realtime.request<RES>(method, url, this._apiProtocolVersion, params, body);
if (!response.success) {
this._logger.error('ChatApi._makeAuthorizedRequest(); failed to make request', {
url,
Expand Down
9 changes: 8 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ export {
} from './helpers.js';
export type { LogContext, Logger, LogHandler } from './logger.js';
export { LogLevel } from './logger.js';
export type { Message, MessageActionDetails, MessageHeaders, MessageMetadata } from './message.js';
export type {
Message,
MessageActionDetails,
MessageActionMetadata,
MessageHeaders,
MessageMetadata,
} from './message.js';
export type {
DeleteMessageParams,
MessageEventPayload,
MessageListener,
Messages,
Expand Down
4 changes: 2 additions & 2 deletions src/core/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type MessageHeaders = Headers;
export type MessageMetadata = Metadata;

/**
* {@link ActionMetadata} type for a chat message {@link MessageActionDetails}.
* {@link ActionMetadata} type for a chat messages {@link MessageActionDetails}.
*/
export type MessageActionMetadata = ActionMetadata;

Expand All @@ -34,7 +34,7 @@ export interface MessageActionDetails {
*/
description?: string;
/**
* The optional {@link MessageActionMetadata} associated with the update or deletion.
* The optional metadata associated with the update or deletion.
*/
metadata?: MessageActionMetadata;
}
Expand Down
Loading

0 comments on commit 4c1e1b7

Please sign in to comment.