Original message
Earlier reply'; @@ -168,3 +168,49 @@ describe('reply quote visibility', () => { expect(toggle()).toHaveAttribute('aria-expanded', 'false'); }); }); + +// compose-p2-3: a click under the folded quote used to put the caret on the +// empty last line, below the hidden original, where the text went unseen. +describe('a click in a reply with a folded quote', () => { + const click = (e: Editor, pos: number) => { + let handled = false; + e.view.someProp('handleClick', (handler) => { handled = handler(e.view, pos, new MouseEvent('click')) || handled; return handled; }); + return handled; + }; + const lastPos = (e: Editor) => e.state.doc.content.size - 1; + + it('puts the caret at the end of the text above the quote', () => { + const e = mount(); + expect(e.state.doc.lastChild?.type.name).toBe('paragraph'); + expect(click(e, lastPos(e))).toBe(true); + expect(e.state.selection.from).toBe(1 + 'My response'.length); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('keeps the click where it was once the quote is shown', () => { + const e = mount(); + toggle().click(); + expect(click(e, lastPos(e))).toBe(false); + }); + + it('keeps a click on a line the user can see below the quote', () => { + const e = mount(`
My response
${HEADER}${QUOTE}Postscript
`); + expect(click(e, lastPos(e) - 2)).toBe(false); + }); + + it('keeps a click in the text above the quote', () => { + const e = mount(); + expect(click(e, 3)).toBe(false); + }); + + it.each(['above', 'below'])('ends the text before a signature %s the quote', (position) => { + const signature = buildSignatureBlock('My signature'); + const e = mount(`My response
${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`); + expect(userTextEnd(e.state)).toBe(1 + 'My response'.length); + }); + + it('has no end of text in a body with neither quote nor signature', () => { + const e = mount('Just text
'); + expect(userTextEnd(e.state)).toBeNull(); + }); +}); diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index c15a51ce4..23d7ac5a2 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -29,11 +29,50 @@ describe('Insert link', () => { fireEvent.click(screen.getByTitle('link')); expect(prompt).not.toHaveBeenCalled(); - const dialog = screen.getByRole('dialog', { name: 'insert_link' }); - fireEvent.change(dialog.querySelector('input')!, { target: { value: 'example.com/docs' } }); + screen.getByRole('dialog', { name: 'insert_link' }); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs'); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } }); fireEvent.click(screen.getByRole('button', { name: 'save' })); - expect(editor.getHTML()).toContain('href="https://example.com/docs"'); + expect(editor.getHTML()).toContain('docs here'); + }); + + // compose-p2-7: with nothing selected Save used to add nothing. + it('inserts the text as a link when nothing is selected', async () => { + const editor = await setup('See it
'); + act(() => { editor.commands.setTextSelection(4); }); + + fireEvent.click(screen.getByTitle('link')); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'our docs' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/See]*href="https:\/\/example\.com\/docs"[^>]*>our docs<\/a> it/); + }); + + it('shows the address as the link when the text is left empty', async () => { + const editor = await setup('See it
'); + act(() => { editor.commands.setTextSelection(4); }); + + fireEvent.click(screen.getByTitle('link')); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/See]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a> it/); + }); + + it('edits the text of a link the caret is in', async () => { + const editor = await setup('docs here
'); + act(() => { editor.commands.setTextSelection(2); }); + + fireEvent.click(screen.getByTitle('link')); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs'); + expect(screen.getByRole('textbox', { name: 'link' })).toHaveValue('https://example.com'); + fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'the docs' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/); }); it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => { diff --git a/components/email/__tests__/thread-conversation-drafts.test.tsx b/components/email/__tests__/thread-conversation-drafts.test.tsx new file mode 100644 index 000000000..80bc7f7ed --- /dev/null +++ b/components/email/__tests__/thread-conversation-drafts.test.tsx @@ -0,0 +1,54 @@ +// compose-p2-6: on a phone the conversation listed a reply draft as an +// ordinary message, expanded at the top with Reply, Reply all and Forward. +// It is marked Draft and offers Edit and Discard instead. +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + +const base = { + threadId: 't1', mailboxIds: { inbox: true }, to: [{ email: 'me@example.org' }], + subject: 's', size: 1, hasAttachment: false, htmlBody: [], textBody: [], attachments: [], bodyValues: {}, +}; +const received = { ...base, id: 'e1', keywords: { $seen: true }, from: [{ name: 'Ann', email: 'ann@t.example' }], receivedAt: '2026-11-01T10:00:00Z', preview: 'question' }; +const draft = { ...base, id: 'd1', keywords: { $seen: true, $draft: true }, from: [{ name: 'Me', email: 'me@example.org' }], receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer' }; + +function renderView(handlers: Record{text}
+ {children} +{tViewer("sending_in", { seconds })}
+