Skip to content

Commit

Permalink
materialisation: Add message deletion capability to chat hooks
Browse files Browse the repository at this point in the history
- Updated the existing `useMessage` hook to expose the `delete` functionality of the Messages feature.
- Updated the unit and integration tests to cover deleting messages.
- Added an icons package to the demo app so we can support bin/edit icons.
- Added a bin icon and deletion logic to the demo app.
It now shows a bin icon when you hover over a message, if clicked, it will delete that message and update the UI.
  • Loading branch information
splindsay-92 committed Oct 14, 2024
1 parent dd05e1c commit 7a0bf45
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 25 deletions.
3 changes: 2 additions & 1 deletion demo/package-lock.json

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

45 changes: 42 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,24 @@ 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 [dropdownVisible, setDropdownVisible] = React.useState(false);
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 +55,22 @@ 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);
setDropdownVisible(false);
}, [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,7 +89,26 @@ 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>
{dropdownVisible && (
<div className="absolute top-full mt-1 right-0 bg-white border border-gray-300 rounded-lg shadow-lg z-10">
<button
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left"
onClick={handleDelete}
>
Delete
</button>
</div>
)}
</div>
</div>
</div>
Expand Down
52 changes: 46 additions & 6 deletions demo/src/containers/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MessageComponent } from '../../components/MessageComponent';
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 { ConnectionLifecycle, Message, Reaction } from '@ably/chat';
import { ConnectionLifecycle, Message, MessageEvents, Reaction, SendMessageParams } from '@ably/chat';
import { randomString } from '../../../../test/helper/identifier.ts';

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

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

const { send: sendMessage, getPreviousMessages } = useMessages({
const {
send: sendMessage,
getPreviousMessages,
deleteMessage,
} = useMessages({
listener: (message) => {
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 All @@ -30,6 +48,13 @@ export const Chat = () => {
},
});

const handleSend = useCallback(
async (params: SendMessageParams) => {
return await sendMessage(params);
},
[sendMessage],
);

const { start, stop, currentlyTyping, error: typingError } = useTyping();
const [roomReactions, setRoomReactions] = useState<Reaction[]>([]);

Expand Down Expand Up @@ -141,9 +166,24 @@ export const Chat = () => {
{messages.map((msg) => (
<MessageComponent
id={msg.timeserial}
key={msg.timeserial}
key={randomString()}
self={msg.clientId === clientId}
message={msg}
onMessageClick={(id) => {
console.log(
'Message clicked',
messages.find((m) => m.timeserial === id),
);
}}
onMessageDelete={(msg) => {
deleteMessage(msg, { description: 'deleted by user' }).then((deletedMessage) => {
setMessages((prevMessages) => {
return prevMessages.filter((m) => {
return m.timeserial !== deletedMessage.timeserial;
});
});
});
}}
></MessageComponent>
))}
<div ref={messagesEndRef} />
Expand All @@ -164,7 +204,7 @@ export const Chat = () => {
<div className="border-t-2 border-gray-200 px-4 pt-4 mb-2 sm:mb-0">
<MessageInput
disabled={!isConnected}
onSend={sendMessage}
onSend={handleSend}
onStartTyping={handleStartTyping}
onStopTyping={handleStopTyping}
/>
Expand Down
26 changes: 13 additions & 13 deletions demo/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,26 @@ const clientId = (function () {

// use this for local development with local realtime
//
// const ablyClient = new Ably.Realtime({
// authUrl: `/api/ably-token-request?clientId=${clientId}`,
// port: 8081,
// environment: 'local',
// tls: false,
// clientId,
// });

const realtimeClient = new Ably.Realtime({
const ablyClient = new Ably.Realtime({
authUrl: `/api/ably-token-request?clientId=${clientId}`,
restHost: import.meta?.env?.VITE_ABLY_HOST ? import.meta.env.VITE_ABLY_HOST : undefined,
realtimeHost: import.meta?.env?.VITE_ABLY_HOST ? import.meta.env.VITE_ABLY_HOST : undefined,
port: 8081,
environment: 'local',
tls: false,
clientId,
});

const chatClient = new ChatClient(realtimeClient, { logLevel: LogLevel.Debug });
// const realtimeClient = new Ably.Realtime({
// authUrl: `/api/ably-token-request?clientId=${clientId}`,
// restHost: import.meta?.env?.VITE_ABLY_HOST ? import.meta.env.VITE_ABLY_HOST : undefined,
// realtimeHost: import.meta?.env?.VITE_ABLY_HOST ? import.meta.env.VITE_ABLY_HOST : undefined,
// clientId,
// });

const chatClient = new ChatClient(ablyClient, { logLevel: LogLevel.Debug });

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AblyProvider client={realtimeClient}>
<AblyProvider client={ablyClient}>
<ChatClientProvider client={chatClient}>
<App />
</ChatClientProvider>
Expand Down
12 changes: 12 additions & 0 deletions src/react/hooks/use-messages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
DeleteMessageParams,
Message,
MessageListener,
Messages,
Expand Down Expand Up @@ -31,6 +32,11 @@ export interface UseMessagesResponse extends ChatStatusResponse {
*/
readonly get: Messages['get'];

/**
* A shortcut to the {@link Messages.delete} method.
*/
readonly deleteMessage: Messages['delete'];

/**
* Provides access to the underlying {@link Messages} instance of the room.
*/
Expand Down Expand Up @@ -89,6 +95,11 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse =>
const onDiscontinuityRef = useEventListenerRef(params?.onDiscontinuity);

const send = useCallback((params: SendMessageParams) => room.messages.send(params), [room]);

const deleteMessage = useCallback(
(message: Message, deleteMessageParams: DeleteMessageParams) => room.messages.delete(message, deleteMessageParams),
[room],
);
const get = useCallback((options: QueryOptions) => room.messages.get(options), [room]);

const [getPreviousMessages, setGetPreviousMessages] = useState<MessageSubscriptionResponse['getPreviousMessages']>();
Expand Down Expand Up @@ -142,6 +153,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse =>
return {
send,
get,
deleteMessage,
messages: room.messages,
getPreviousMessages,
connectionStatus,
Expand Down
57 changes: 56 additions & 1 deletion test/react/hooks/use-messages.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChatClient, Message, MessageListener, RoomLifecycle, RoomOptionsDefaults } from '@ably/chat';
import { ChatClient, Message, MessageEvents, MessageListener, RoomLifecycle, RoomOptionsDefaults } from '@ably/chat';
import { cleanup, render, waitFor } from '@testing-library/react';
import React, { useEffect } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -73,6 +73,61 @@ describe('useMessages', () => {
expect(messagesRoomTwo[0]?.text).toBe('hello world');
}, 10000);

it('should delete messages correctly', async () => {
// create new clients
const chatClientOne = newChatClient() as unknown as ChatClient;
const chatClientTwo = newChatClient() as unknown as ChatClient;

// create a second room and attach it, so we can listen for deletions
const roomId = randomRoomId();
const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults);
await roomTwo.attach();

// start listening for deletions
const deletionsRoomTwo: Message[] = [];
roomTwo.messages.subscribe((message) => {
if (message.type === MessageEvents.Deleted) {
deletionsRoomTwo.push(message.message);
}
});

const TestComponent = () => {
const { send, deleteMessage, roomStatus } = useMessages();

useEffect(() => {
if (roomStatus === RoomLifecycle.Attached) {
void send({ text: 'hello world' }).then((message) => {
void deleteMessage(message, {
description: 'deleted',
metadata: { reason: 'test' },
hard: false,
});
});
}
}, [roomStatus]);

return null;
};

const TestProvider = () => (
<ChatClientProvider client={chatClientOne}>
<ChatRoomProvider
id={roomId}
options={RoomOptionsDefaults}
>
<TestComponent />
</ChatRoomProvider>
</ChatClientProvider>
);

render(<TestProvider />);

// expect a message to be received by the second room
await waitForMessages(deletionsRoomTwo, 1);
expect(deletionsRoomTwo[0]?.isDeleted()).toBe(true);
expect(deletionsRoomTwo[0]?.deletedBy).toBe(chatClientOne.clientId);
}, 10000);

it('should receive messages on a subscribed listener', async () => {
// create new clients
const chatClientOne = newChatClient() as unknown as ChatClient;
Expand Down
24 changes: 23 additions & 1 deletion test/react/hooks/use-messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import * as Ably from 'ably';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DefaultMessage } from '../../../src/core/message.ts';
import { PaginatedResult } from '../../../src/core/query.ts';
import { useMessages } from '../../../src/react/hooks/use-messages.ts';
import { makeTestLogger } from '../../helper/logger.ts';
Expand Down Expand Up @@ -145,7 +146,7 @@ describe('useMessages', () => {
expect(mockGetPreviousMessages).toHaveBeenCalledTimes(1);
});

it('should correctly call the send and get message methods', async () => {
it('should correctly call the methods exposed by the hook', async () => {
const { result } = renderHook(() => useMessages());

// spy on the send method of the messages instance
Expand All @@ -154,14 +155,35 @@ describe('useMessages', () => {
// spy on the get method of the messages instance
const getSpy = vi.spyOn(mockRoom.messages, 'get').mockResolvedValue({} as unknown as PaginatedResult<Message>);

const deleteSpy = vi.spyOn(mockRoom.messages, 'delete').mockResolvedValue({} as unknown as Message);

const message = new DefaultMessage(
'108TeGZDQBderu97202638@1719948956834-0',
'client-1',
'some-room',
'I have the high ground now',
new Date(1719948956834),
{},
{},
);
// call both methods and ensure they call the underlying messages methods
await act(async () => {
await result.current.send({ text: 'test message' });
await result.current.get({ limit: 10 });
await result.current.deleteMessage(message, {
description: 'deleted',
metadata: { reason: 'test' },
hard: false,
});
});

expect(sendSpy).toHaveBeenCalledWith({ text: 'test message' });
expect(getSpy).toHaveBeenCalledWith({ limit: 10 });
expect(deleteSpy).toHaveBeenCalledWith(message, {
description: 'deleted',
metadata: { reason: 'test' },
hard: false,
});
});

it('should handle rerender if the room instance changes', () => {
Expand Down

0 comments on commit 7a0bf45

Please sign in to comment.