From 37d736570766f0c5cba0b4b902804d276bb4377e Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 1 Sep 2026 14:42:04 +0200 Subject: [PATCH] feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) --- .../components/Collab/CollabPolls.test.tsx | 120 ++++++++++++++++++ client/src/components/Collab/CollabPolls.tsx | 52 +++++++- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 34 +++-- .../unit/mobile/trip/MCollabPolls.test.tsx | 48 +++++++ shared/src/i18n/ar/collab.ts | 1 + shared/src/i18n/br/collab.ts | 1 + shared/src/i18n/ca/collab.ts | 1 + shared/src/i18n/cs/collab.ts | 1 + shared/src/i18n/de/collab.ts | 1 + shared/src/i18n/en/collab.ts | 1 + shared/src/i18n/es/collab.ts | 1 + shared/src/i18n/fr/collab.ts | 1 + shared/src/i18n/gr/collab.ts | 1 + shared/src/i18n/hu/collab.ts | 1 + shared/src/i18n/id/collab.ts | 1 + shared/src/i18n/it/collab.ts | 1 + shared/src/i18n/ja/collab.ts | 1 + shared/src/i18n/ko/collab.ts | 1 + shared/src/i18n/nl/collab.ts | 1 + shared/src/i18n/pl/collab.ts | 1 + shared/src/i18n/ru/collab.ts | 1 + shared/src/i18n/sv/collab.ts | 1 + shared/src/i18n/tr/collab.ts | 1 + shared/src/i18n/uk/collab.ts | 1 + shared/src/i18n/vi/collab.ts | 1 + shared/src/i18n/zh-TW/collab.ts | 1 + shared/src/i18n/zh/collab.ts | 1 + 27 files changed, 260 insertions(+), 17 deletions(-) diff --git a/client/src/components/Collab/CollabPolls.test.tsx b/client/src/components/Collab/CollabPolls.test.tsx index 2981db8df..16ecbf98b 100644 --- a/client/src/components/Collab/CollabPolls.test.tsx +++ b/client/src/components/Collab/CollabPolls.test.tsx @@ -732,3 +732,123 @@ describe('CollabPolls details', () => { } }); }); + +// FE-COMP-POLLS-016 to FE-COMP-POLLS-022 +// Markdown questions, sanitization and multiline input (#2177). + +describe('CollabPolls markdown & multiline', () => { + it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }), + ), + ); + render(); + const heading = await screen.findByText('Day trip'); + expect(heading.closest('h2')).not.toBeNull(); + expect(screen.getByText('Vote').closest('strong')).not.toBeNull(); + }); + + it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ + polls: [buildPoll({ question: 'Safe title ' })], + }), + ), + ); + render(); + await screen.findByText(/Safe title/); + expect(document.querySelector('script')).toBeNull(); + expect(document.querySelector('img[src="x"]')).toBeNull(); + expect(document.querySelector('[onerror]')).toBeNull(); + }); + + it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }), + ), + ); + render(); + const first = await screen.findByText('First paragraph'); + const second = screen.getByText('Second paragraph'); + expect(first.tagName).toBe('P'); + expect(second.tagName).toBe('P'); + expect(first).not.toBe(second); + }); + + it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }), + ), + ); + render(); + const question = await screen.findByText('Best destination?'); + expect(question.textContent).toBe('Best destination?'); + // The question sits in a paragraph now, but no formatting was invented + expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull(); + }); + + it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => { + let body: Record | null = null; + server.use( + http.post('/api/trips/1/collab/polls', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) }); + }), + ); + const user = userEvent.setup(); + render(); + await screen.findByText(/no polls yet|collab\.polls\.empty/i); + await user.click(screen.getByRole('button', { name: /new/i })); + + const questionField = screen.getByPlaceholderText(/what should we do/i); + expect(questionField.tagName).toBe('TEXTAREA'); + await user.type(questionField, 'Line one{enter}Line two'); + // Enter stayed in the field — the form was not submitted without options + expect(body).toBeNull(); + expect(questionField).toHaveValue('Line one\nLine two'); + + const optionInputs = screen.getAllByPlaceholderText(/option/i); + expect(optionInputs[0].tagName).toBe('TEXTAREA'); + await user.type(optionInputs[0], 'A'); + await user.type(optionInputs[1], 'B'); + await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i })); + await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' })); + }); + + it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }), + ), + ); + render(); + const link = await screen.findByRole('link', { name: 'Our hotel' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow'); + }); + + it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => { + server.use( + http.get('/api/trips/1/collab/polls', () => + HttpResponse.json({ + polls: [buildPoll({ + options: [ + { id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] }, + { id: 2, text: 'Rome', voters: [] }, + ], + })], + }), + ), + ); + render(); + const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!; + expect(label.style.whiteSpace).toBe('pre-wrap'); + expect(label.style.overflowWrap).toBe('anywhere'); + expect(label.style.minWidth).toBe('0px'); + }); +}); diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 969b7a1a6..042f16a12 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -1,5 +1,10 @@ import React, { useState, useEffect, useCallback } from 'react' import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react' +import ReactMarkdown, { type Components } from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import rehypeSanitize from 'rehype-sanitize' +import { markdownLinkComponents } from '../shared/markdownLink' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' @@ -35,6 +40,33 @@ interface Poll { const FONT = "var(--font-system)" +// Block styling for the markdown question (#2177). Tailwind's preflight resets +// headings/lists, so the sizes are set here — in em, on purpose: they scale with +// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's +// text-size setting keeps working. Links come from markdownLinkComponents +// (#1629): new tab plus rel protection, this is cross-user content. +const pollMarkdownComponents: Components = { + ...markdownLinkComponents, + p: ({ children }) => ( +

{children}

+ ), + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), +} + function timeRemaining(deadline) { if (!deadline) return null const diff = new Date(deadline).getTime() - Date.now() @@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { {/* Question */}
{t('collab.polls.question')}
- setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> +