From bb84366046c9bbc628eb5e567da019249dff47a3 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 13 Oct 2026 16:14:46 +0200 Subject: [PATCH] feat(messages): typing indicator and message editing Both are plain XMPP, so neither daily-api nor skirnir changes. Typing uses XEP-0085 chat states. The composer sends `composing` when a draft gets text, repeats it every 5s while typing goes on, and `paused` 4s after the last keystroke, on clearing the draft, or on leaving the thread; the message itself ends it. The receiver shows a dots bubble under the newest message, drops it on `paused`, on the peer's message, or after 10s without a repeat, so a peer whose tab closed never looks busy. Standalone chat states aren't archived or held offline by ejabberd, and our own sessions' states arrive as carbons and are ignored. The state lives in the query cache (never fetched, reset on reconnect) so it survives thread remounts and needs no new store. Editing uses XEP-0308 corrections: a new message with a `` pointing at the original. Only the sender's own message is changed, and a correction of a message outside the loaded page is dropped with it, so a peer can't rewrite our bubbles and nothing shows up as a duplicate. The archive stores the original and the correction as separate items; loading folds them, keeping the original's time, reactions and comment reference. An edit is tracked through stream management like a message but never reported as sent, so it doesn't bump the peer's unread count or push. The inline editor replaces the bubble's text; images in the message stay as they are and are reattached in the composer's shape on save. Arrow up in an empty composer edits the viewer's last message. Co-Authored-By: Claude Fable 5.1 --- .../components/ConversationThread.spec.tsx | 94 +++++++++++- .../components/ConversationThread.tsx | 111 +++++++++++++- .../messages/components/MessageComposer.tsx | 17 +++ .../messages/components/MessageEditor.tsx | 105 +++++++++++++ .../messages/components/TypingIndicator.tsx | 32 ++++ .../features/messages/hooks/useEditMessage.ts | 50 +++++++ .../messages/hooks/useMessagesLiveUpdates.ts | 58 +++++++- .../messages/hooks/useTypingNotifier.ts | 83 +++++++++++ .../shared/src/features/messages/media.ts | 17 +++ .../src/features/messages/mockTransport.ts | 32 +++- .../shared/src/features/messages/queries.ts | 61 ++++++++ .../src/features/messages/stanzas.spec.ts | 67 +++++++++ .../shared/src/features/messages/stanzas.ts | 68 ++++++++- .../shared/src/features/messages/transport.ts | 4 + .../shared/src/features/messages/types.ts | 30 ++++ .../features/messages/xmppTransport.spec.ts | 105 +++++++++++++ .../src/features/messages/xmppTransport.ts | 140 ++++++++++++++++-- packages/shared/src/lib/log.ts | 1 + 18 files changed, 1046 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/features/messages/components/MessageEditor.tsx create mode 100644 packages/shared/src/features/messages/components/TypingIndicator.tsx create mode 100644 packages/shared/src/features/messages/hooks/useEditMessage.ts create mode 100644 packages/shared/src/features/messages/hooks/useTypingNotifier.ts diff --git a/packages/shared/src/features/messages/components/ConversationThread.spec.tsx b/packages/shared/src/features/messages/components/ConversationThread.spec.tsx index 21aa75e419..b07a80ff33 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 1e0fb4a40a..9531050a5e 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 && ( + +