diff --git a/app/routes/_app+/recipients+/$recipientId.index.tsx b/app/routes/_app+/recipients+/$recipientId.index.tsx index e3af6bc..406ad42 100644 --- a/app/routes/_app+/recipients+/$recipientId.index.tsx +++ b/app/routes/_app+/recipients+/$recipientId.index.tsx @@ -605,12 +605,14 @@ export default function RecipientRoute() { const params = new URLSearchParams(searchParams) params.set('cursor', pastNextCursor) - const queryString = params.toString() + // Without `index`, React Router sends the fetch to the parent + // `$recipientId` layout loader, which has no past messages. + params.set('index', '') pendingScrollRef.current = { height: container.scrollHeight, top: container.scrollTop, } - void loadMoreFetcher.load(queryString ? `?${queryString}` : '.') + void loadMoreFetcher.load(`?${params.toString()}`) }, [pastNextCursor, loadMoreFetcher, searchParams], ) @@ -731,11 +733,13 @@ export default function RecipientRoute() { placeholder="Aa" rows={1} required + enterKeyHint="enter" + aria-keyshortcuts="Control+Enter Meta+Enter" onInput={(event) => setDraft(event.currentTarget.value)} onKeyDown={(event) => { if ( event.key === 'Enter' && - !event.shiftKey && + (event.metaKey || event.ctrlKey) && !event.nativeEvent.isComposing ) { event.preventDefault() diff --git a/tests/e2e/recipient-thread.test.ts b/tests/e2e/recipient-thread.test.ts new file mode 100644 index 0000000..695d074 --- /dev/null +++ b/tests/e2e/recipient-thread.test.ts @@ -0,0 +1,94 @@ +import { faker } from '@faker-js/faker' +import { prisma } from '#app/utils/db.server.ts' +import { createRecipient, expect, test } from '#tests/playwright-utils.ts' + +async function createVerifiedRecipient(userId: string) { + return prisma.recipient.create({ + select: { id: true }, + data: { + ...createRecipient(), + verified: true, + userId, + scheduleCron: '0 0 1 1 1', + }, + }) +} + +test('Enter adds a newline to a new message instead of queueing it', async ({ + page, + login, +}) => { + const user = await login({ stripeId: faker.string.uuid() }) + const recipient = await createVerifiedRecipient(user.id) + + await page.goto(`/recipients/${recipient.id}`) + await page.waitForLoadState('networkidle') + const input = page.getByRole('textbox', { name: /add a new message/i }) + await input.click() + await input.pressSequentially('First line') + await input.press('Enter') + await input.pressSequentially('Second line') + await expect(input).toHaveValue('First line\nSecond line') + const getQueuedContent = async () => { + const messages = await prisma.message.findMany({ + where: { recipientId: recipient.id }, + select: { content: true }, + }) + return messages.map((m) => m.content).sort() + } + expect(await getQueuedContent()).toEqual([]) + + await page.getByRole('button', { name: /add to queue/i }).click() + await expect(input).toHaveValue('') + await expect.poll(getQueuedContent).toEqual(['First line\nSecond line']) + + await input.pressSequentially('Shortcut') + await input.press('ControlOrMeta+Enter') + await expect(input).toHaveValue('') + await expect + .poll(getQueuedContent) + .toEqual(['First line\nSecond line', 'Shortcut']) +}) + +test('Scrolling to the top of the thread loads earlier messages', async ({ + page, + login, +}) => { + const user = await login({ stripeId: faker.string.uuid() }) + const recipient = await createVerifiedRecipient(user.id) + const messageCount = 75 + const firstSentAt = new Date('2025-01-01T12:00:00Z').getTime() + await prisma.message.createMany({ + data: Array.from({ length: messageCount }, (_, i) => ({ + recipientId: recipient.id, + content: `Past message ${i + 1}`, + sentAt: new Date(firstSentAt + i * 1000 * 60 * 60 * 24), + order: i, + })), + }) + + await page.goto(`/recipients/${recipient.id}`) + await page.waitForLoadState('networkidle') + const thread = page.getByRole('region', { name: 'Messages' }) + const messages = thread.getByText(/^Past message \d+$/) + await expect( + thread.getByText('Past message 75', { exact: true }), + ).toBeVisible() + await expect(messages).toHaveCount(30) + + const scrollToTop = async () => { + await messages.last().hover() + await page.mouse.wheel(0, -100_000) + } + + await scrollToTop() + await expect(messages).toHaveCount(60) + await scrollToTop() + await expect(messages).toHaveCount(messageCount) + await expect( + thread.getByText('Past message 1', { exact: true }), + ).toBeAttached() + await expect( + thread.getByText(/scroll up to load earlier messages/i), + ).toHaveCount(0) +})