diff --git a/packages/shared/src/features/messages/components/ConversationThread.spec.tsx b/packages/shared/src/features/messages/components/ConversationThread.spec.tsx index 21aa75e419c..b07a80ff33e 100644 --- a/packages/shared/src/features/messages/components/ConversationThread.spec.tsx +++ b/packages/shared/src/features/messages/components/ConversationThread.spec.tsx @@ -1,8 +1,14 @@ import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ConversationThread } from './ConversationThread'; -import { dmThreadQueryKey } from '../queries'; +import { dmThreadQueryKey, dmTypingQueryOptions } from '../queries'; import { DirectMessageAccess } from '../graphql'; import type { DmMessage, DmPeer } from '../types'; import { DmMessageStatus } from '../types'; @@ -44,6 +50,13 @@ jest.mock('../hooks/useSendMessage', () => ({ jest.mock('../hooks/useReactToMessage', () => ({ useReactToMessage: () => jest.fn(), })); +const mockEdit = jest.fn(); +jest.mock('../hooks/useEditMessage', () => ({ + useEditMessage: () => mockEdit, +})); +jest.mock('../hooks/useTypingNotifier', () => ({ + useTypingNotifier: () => ({ onDraftChange: jest.fn(), onSent: jest.fn() }), +})); jest.mock('../hooks/useMarkThreadRead', () => ({ useMarkThreadRead: jest.fn(), })); @@ -108,6 +121,7 @@ const message = (senderId: string, createdAt = new Date()): DmMessage => { const renderThread = async () => { const client = new QueryClient(); + mockEdit.mockReset(); render( @@ -129,7 +143,7 @@ const renderThread = async () => { fireEvent.scroll(list); }; - return { list, receive, scrollTo }; + return { list, receive, scrollTo, client }; }; beforeEach(() => { @@ -225,3 +239,77 @@ describe('ConversationThread intro and days', () => { expect(screen.getByRole('separator', { name: 'Today' })).toBeVisible(); }); }); + +describe('ConversationThread typing', () => { + it('shows the peer typing under the newest message and follows it', async () => { + const { list, client } = await renderThread(); + list.scrollTop = 0; + + act(() => { + client.setQueryData( + dmTypingQueryOptions(viewer, mockPeer.id).queryKey, + true, + ); + }); + + expect( + await screen.findByRole('status', { name: '@ada is typing' }), + ).toBeInTheDocument(); + expect(list.scrollTop).toBe(scrollHeight); + + act(() => { + client.setQueryData( + dmTypingQueryOptions(viewer, mockPeer.id).queryKey, + false, + ); + }); + + await waitFor(() => + expect(screen.queryByRole('status')).not.toBeInTheDocument(), + ); + }); +}); + +describe('ConversationThread editing', () => { + it('edits the text of the viewer’s message and keeps its images', async () => { + const mine = { + ...message('me'), + body: 'typo here\n\n![pic](https://media.daily.dev/pic.png)', + }; + mockThread = [message('ada'), mine]; + await renderThread(); + + expect(screen.getAllByLabelText('Edit message')).toHaveLength(1); + fireEvent.click(screen.getByLabelText('Edit message')); + const editor = screen.getByLabelText('Edit message'); + expect(editor).toHaveValue('typo here'); + + fireEvent.change(editor, { target: { value: 'fixed here' } }); + fireEvent.keyDown(editor, { key: 'Enter' }); + + expect(mockEdit).toHaveBeenCalledWith( + mine, + 'fixed here\n\n![pic](https://media.daily.dev/pic.png)', + ); + }); + + it('cancels with Escape without saving', async () => { + mockThread = [message('me')]; + await renderThread(); + + fireEvent.click(screen.getByLabelText('Edit message')); + const editor = screen.getByLabelText('Edit message'); + fireEvent.change(editor, { target: { value: 'changed' } }); + fireEvent.keyDown(editor, { key: 'Escape' }); + + expect(mockEdit).not.toHaveBeenCalled(); + expect(screen.getByText(mockThread[0].body)).toBeInTheDocument(); + }); + + it('marks an edited message', async () => { + mockThread = [{ ...message('me'), editedAt: new Date().toISOString() }]; + await renderThread(); + + expect(screen.getByText('(edited)')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/messages/components/ConversationThread.tsx b/packages/shared/src/features/messages/components/ConversationThread.tsx index 1e0fb4a40a1..9531050a5e0 100644 --- a/packages/shared/src/features/messages/components/ConversationThread.tsx +++ b/packages/shared/src/features/messages/components/ConversationThread.tsx @@ -33,6 +33,7 @@ import { } from '../../../components/buttons/Button'; import { ArrowIcon } from '../../../components/icons/Arrow'; import { BlockIcon } from '../../../components/icons/Block'; +import { EditIcon } from '../../../components/icons/Edit'; import { Tooltip } from '../../../components/tooltip/Tooltip'; import { IconSize } from '../../../components/Icon'; import { ShellPage } from '../../../components/shell/ShellPageContext'; @@ -49,12 +50,15 @@ import { dmPeerQueryOptions, dmConversationQueryOptions, dmThreadQueryOptions, + dmTypingQueryOptions, } from '../queries'; import { DirectMessageAccess } from '../graphql'; import { DmAccess, getDmAccess } from '../access'; import { useDmSettings } from '../hooks/useDmSettings'; import { useSendMessage } from '../hooks/useSendMessage'; import { useReactToMessage } from '../hooks/useReactToMessage'; +import { useEditMessage } from '../hooks/useEditMessage'; +import { useTypingNotifier } from '../hooks/useTypingNotifier'; import { useMarkThreadRead } from '../hooks/useMarkThreadRead'; import type { DmMessage } from '../types'; import { DmMessageStatus } from '../types'; @@ -65,7 +69,9 @@ import { MessageRequestResponse } from './MessageRequestResponse'; import { DmContextCard } from './DmContextCard'; import { MessageCommentRef } from './MessageCommentRef'; import { AddReactionButton, MessageReactions } from './MessageReactions'; -import { parseMessageBody } from '../media'; +import { MessageEditor } from './MessageEditor'; +import { TypingIndicator } from './TypingIndicator'; +import { getEditableText, parseMessageBody, withEditedText } from '../media'; import { findPostIdInText, splitLinks } from '../messageLinks'; import { getMessagesUrl } from '../urls'; import type { DmOrigin } from '../urls'; @@ -120,6 +126,10 @@ const DaySeparator = ({ date }: { date: Date }): ReactElement => { ); }; +// Shown on hover with a mouse; always there on touch screens. +const hoverActionClassName = + 'mouse:invisible mouse:group-focus-within:visible mouse:group-hover:visible'; + const MessageText = ({ text }: { text: string }): ReactElement => ( <> {splitLinks(text).map((segment, index) => @@ -146,22 +156,32 @@ const MessageBubble = ({ message, isMine, isGroupEnd, + isEditing, peerUsername, viewerId, onRetry, onReact, + onEdit, + onSaveEdit, + onCancelEdit, onMediaLoad, }: { message: DmMessage; isMine: boolean; isGroupEnd: boolean; + isEditing: boolean; peerUsername: string; viewerId: string; onRetry: (message: DmMessage) => void; onReact?: (message: DmMessage, emoji: string) => void; + // Missing when the message isn't the viewer's to change. + onEdit?: (message: DmMessage) => void; + onSaveEdit: (message: DmMessage, body: string) => void; + onCancelEdit: () => void; onMediaLoad: () => void; }): ReactElement => { const parts = parseMessageBody(message.body); + const lastTextIndex = parts.map((part) => part.type).lastIndexOf('text'); const postId = findPostIdInText( parts.map((part) => (part.type === 'text' ? part.text : '')).join('\n'), ); @@ -196,9 +216,24 @@ const MessageBubble = ({ className={classNames( 'min-w-0 max-w-[85%] gap-1 tablet:max-w-[30rem]', isMine ? 'items-end' : 'items-start', + isEditing && 'w-full', )} > + {isEditing && ( + + onSaveEdit(message, withEditedText(message.body, text)) + } + onCancel={onCancelEdit} + /> + )} {parts.map((part, index) => { + // The text is in the editor; images stay in view. + if (isEditing && part.type === 'text') { + return null; + } + const isLast = index === parts.length - 1; const shape = classNames( isMine && isGroupEnd && isLast && 'rounded-br-4', @@ -246,6 +281,14 @@ const MessageBubble = ({ )} > + {message.editedAt && index === lastTextIndex && ( + + (edited) + + )} ); })} @@ -257,11 +300,21 @@ const MessageBubble = ({ /> )} - {react && ( - + {!isEditing && onEdit && ( + +