Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -108,6 +121,7 @@ const message = (senderId: string, createdAt = new Date()): DmMessage => {

const renderThread = async () => {
const client = new QueryClient();
mockEdit.mockReset();
render(
<QueryClientProvider client={client}>
<ConversationThread peerId={mockPeer.id} />
Expand All @@ -129,7 +143,7 @@ const renderThread = async () => {
fireEvent.scroll(list);
};

return { list, receive, scrollTo };
return { list, receive, scrollTo, client };
};

beforeEach(() => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -120,6 +126,10 @@ const DaySeparator = ({ date }: { date: Date }): ReactElement => {
);
};

// Shown on hover with a mouse; always there on touch screens.
const hoverActionClassName =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Question (mobile): on touch devices this class is always visible, so every one of your own bubbles on a phone now shows a pencil next to the reaction button. Was this checked on a real phone, and is a permanent pencil per message the intended design there, rather than long-press or a menu? A phone screenshot of a thread with several own messages would settle it.

Reviewed by AI.

'mouse:invisible mouse:group-focus-within:visible mouse:group-hover:visible';

const MessageText = ({ text }: { text: string }): ReactElement => (
<>
{splitLinks(text).map((segment, index) =>
Expand All @@ -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'),
);
Expand Down Expand Up @@ -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 && (
<MessageEditor
initialValue={getEditableText(message.body)}
onSave={(text) =>
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',
Expand Down Expand Up @@ -246,6 +281,14 @@ const MessageBubble = ({
)}
>
<MessageText text={part.text} />
{message.editedAt && index === lastTextIndex && (
<span
title={formatDateTime(message.editedAt)}
className="ml-1 text-text-quaternary typo-caption2"
>
(edited)
</span>
)}
</div>
);
})}
Expand All @@ -257,11 +300,21 @@ const MessageBubble = ({
/>
)}
</FlexCol>
{react && (
<AddReactionButton
onReact={react}
className="mouse:invisible mouse:group-focus-within:visible mouse:group-hover:visible"
/>
{!isEditing && onEdit && (
<Tooltip content="Edit">
<Button
type="button"
variant={ButtonVariant.Tertiary}
size={ButtonSize.XSmall}
icon={<EditIcon />}
aria-label="Edit message"
className={hoverActionClassName}
onClick={() => onEdit(message)}
/>
</Tooltip>
)}
{!isEditing && react && (
<AddReactionButton onReact={react} className={hoverActionClassName} />
)}
</div>
{Object.keys(reactions).length > 0 && (
Expand Down Expand Up @@ -392,6 +445,11 @@ export const ConversationThread = ({
const { allowsMessages } = useDmSettings();
const { send, retry } = useSendMessage(peer);
const react = useReactToMessage(peer);
const edit = useEditMessage(peer);
const [editingId, setEditingId] = useState<string>();
const { data: isPeerTyping = false } = useQuery(
dmTypingQueryOptions(user, peerId),
);

const isBlockedByMe = preference?.status === ContentPreferenceStatus.Blocked;
const access = getDmAccess({
Expand All @@ -400,6 +458,21 @@ export const ConversationThread = ({
peerAccess: peer?.access ?? DirectMessageAccess.Open,
hasIncomingRequest,
});
const typing = useTypingNotifier(peer, access === DmAccess.Allowed);
// Only a delivered message of the viewer's, with text to change, can be
// edited; the request note lives in the API, not the chat archive.
const canEdit = (message: DmMessage): boolean =>
access === DmAccess.Allowed &&
message.senderId === user?.id &&
message.status === DmMessageStatus.Sent &&
archived.includes(message) &&
!!getEditableText(message.body);
const editLast = () => {
const last = [...archived].reverse().find(canEdit);
if (last) {
setEditingId(last.id);
}
};
useMarkThreadRead({
peerId,
lastIncomingId: [...archived]
Expand Down Expand Up @@ -446,6 +519,15 @@ export const ConversationThread = ({
setUnseenCount((count) => count + arrived);
}, [messages.length, peerId, user?.id]);

// The indicator appears under the newest message, where a reader at the
// bottom expects to see it.
useLayoutEffect(() => {
const container = scrollRef.current;
if (isPeerTyping && container && isAtBottomRef.current) {
container.scrollTop = container.scrollHeight;
}
}, [isPeerTyping]);

const jumpToLatest = () => {
const container = scrollRef.current;
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
Expand Down Expand Up @@ -625,17 +707,31 @@ export const ConversationThread = ({
next.senderId !== message.senderId ||
!isSameDay(message.createdAt, next.createdAt)
}
isEditing={message.id === editingId}
peerUsername={peer?.username ?? ''}
viewerId={user?.id ?? ''}
onRetry={retry}
// A blocked peer or DMs turned off would bounce the
// reaction.
onReact={access === DmAccess.Allowed ? react : undefined}
onEdit={
canEdit(message)
? ({ id }) => setEditingId(id)
: undefined
}
onSaveEdit={(target, body) => {
setEditingId(undefined);
edit(target, body);
}}
onCancelEdit={() => setEditingId(undefined)}
onMediaLoad={onMediaLoad}
/>
</React.Fragment>
);
})}
{isPeerTyping && peer && (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking: the indicator renders whatever the thread's access is. The sender only gates on its own view of access, so a peer I've blocked (who can't see that) still sends composing, and I see them typing in a thread I've blocked. The same applies while the policy module isn't filtering. access === DmAccess.Allowed && isPeerTyping && peer would match how reactions and editing are gated here.

Reviewed by AI.

<TypingIndicator username={peer.username} />
)}
</FlexCol>
</div>
{showJump && (
Expand Down Expand Up @@ -683,11 +779,14 @@ export const ConversationThread = ({
// picked first neither takes nor clears it.
onSendGif={(body) => send(body)}
onSend={(body) => {
typing.onSent();
send(body, pendingContext);
if (pendingContext) {
clearContext();
}
}}
onDraftChange={typing.onDraftChange}
onEditLast={editLast}
/>
</div>
) : (
Expand Down
Loading
Loading