feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177)

This commit is contained in:
Maurice
2026-09-01 14:42:04 +02:00
committed by Maurice
parent 354a89f25b
commit 37d7365707
27 changed files with 260 additions and 17 deletions
@@ -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(<CollabPolls {...defaultProps} />);
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 <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
}),
),
);
render(<CollabPolls {...defaultProps} />);
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(<CollabPolls {...defaultProps} />);
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(<CollabPolls {...defaultProps} />);
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<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/polls', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
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(<CollabPolls {...defaultProps} />);
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(<CollabPolls {...defaultProps} />);
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');
});
});
+45 -7
View File
@@ -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 }) => (
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
),
h1: ({ children }) => (
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
),
}
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 */}
<div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => 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' }} />
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
</div>
{/* Options */}
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, 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' }} />
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)}
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question}
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]} components={pollMarkdownComponents}>
{poll.question}
</ReactMarkdown>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
</div>
{/* Label */}
{/* Label — options stay plain text, but long or multiline ones
must wrap instead of being clipped by the button's
overflow:hidden (#2177) */}
<span style={{
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.text}
@@ -1,12 +1,17 @@
import { useCallback, useEffect, useState } from 'react'
import { BarChart3, Check, Clock, Lock, Plus, Trash2, X } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeSanitize from 'rehype-sanitize'
import MDancingTrek from '../../../components/MDancingTrek'
import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
import { collabApi } from '../../../../api/client'
import { addListener, removeListener } from '../../../../api/websocket'
import { useAuthStore } from '../../../../store/authStore'
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { SectionHeader, TabScroller } from './tabChrome'
@@ -253,7 +258,14 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
<div className="flex items-start gap-2 px-3 py-[10px]" style={closed ? { background: 'var(--m-card)' } : undefined}>
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold leading-[1.35] text-m-ink">{poll.question}</div>
{/* Question is cross-user markdown (#2177): sanitized, raw HTML stays
inert, links open in a new tab with rel protection (#1629).
Heading/list sizes are em-based so they follow the text scale. */}
<div className="break-words text-[0.8125rem] font-bold leading-[1.35] text-m-ink [&_a]:underline [&_h1]:mb-1 [&_h1]:text-[1.35em] [&_h1]:leading-[1.2] [&_h2]:mb-1 [&_h2]:text-[1.2em] [&_h2]:leading-[1.25] [&_h3]:mb-1 [&_h3]:text-[1.1em] [&_h3]:leading-[1.3] [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_ul]:list-disc [&_ul]:pl-4">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]} components={markdownLinkComponents}>
{poll.question}
</Markdown>
</div>
<div className="mt-[5px] flex flex-wrap items-center gap-[6px]">
{closed ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.02em] text-m-faint">
@@ -316,7 +328,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
type="button"
disabled={closed || !canEdit}
onClick={() => onVote(poll.id, idx)}
className="relative flex items-center gap-[8px] overflow-hidden rounded-[10px] bg-m-card px-[12px] py-[10px] text-left disabled:cursor-default"
className="relative flex items-start gap-[8px] overflow-hidden rounded-[10px] bg-m-card px-[12px] py-[10px] text-left disabled:cursor-default"
>
<span className="absolute inset-y-0 left-0" style={{ width: `${pct}%`, background: fillTint }} />
<span
@@ -327,7 +339,8 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
>
{myVote && <Check size={11} strokeWidth={3} className="text-m-actfg" />}
</span>
<span className={`relative min-w-0 flex-1 truncate text-[0.8125rem] ${myVote || isWinner ? 'font-bold' : 'font-medium'} text-m-ink`}>
{/* No truncate: long or multiline options wrap fully (#2177) */}
<span className={`relative min-w-0 flex-1 whitespace-pre-wrap break-words text-[0.8125rem] [overflow-wrap:anywhere] ${myVote || isWinner ? 'font-bold' : 'font-medium'} text-m-ink`}>
{opt.text}
</span>
{(voted || closed) && count > 0 && (
@@ -406,26 +419,27 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<Eyebrow className="mb-[5px] uppercase">{t('collab.polls.question')} *</Eyebrow>
<input
type="text"
<textarea
rows={4}
value={question}
onChange={e => setQuestion(e.target.value)}
maxLength={300}
placeholder={t('collab.polls.questionPlaceholder')}
className={FIELD_CLS}
className={FIELD_AREA_CLS}
/>
<p className="mt-1 font-geist text-[0.625rem] text-m-faint">{t('collab.polls.markdownHint')}</p>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('collab.polls.options')}</Eyebrow>
<div className="flex flex-col gap-[6px]">
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-[6px]">
<input
type="text"
<textarea
rows={2}
value={opt}
onChange={e => setOptions(prev => prev.map((o, j) => (j === i ? e.target.value : o)))}
placeholder={t('collab.polls.optionPlaceholder', { n: i + 1 })}
maxLength={200}
className={FIELD_CLS}
className={FIELD_AREA_CLS}
/>
{options.length > 2 && (
<button
@@ -412,4 +412,52 @@ describe('MCollabPolls', () => {
await waitFor(() => expect(screen.getByPlaceholderText('collab.polls.questionPlaceholder')).toHaveValue(''))
})
// Markdown questions, sanitization and multiline input (#2177)
it('FE-MOB-POLLS-027: renders the question as sanitized markdown with protected links', async () => {
setup([poll({ question: '**Bold plan** <script>alert("xss")</script> [site](https://example.com)' })])
const bold = await screen.findByText('Bold plan')
expect(bold.closest('strong')).not.toBeNull()
expect(document.querySelector('script')).toBeNull()
expect(document.querySelector('[onerror]')).toBeNull()
const link = screen.getByRole('link', { name: 'site' })
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
})
it('FE-MOB-POLLS-028: option labels wrap instead of truncating', async () => {
setup([poll({ options: [
{ text: 'A very long option label\nacross two lines', label: 'long', voters: [] },
{ text: 'Sushi', label: 'Sushi', voters: [] },
] })])
const label = (await screen.findByText(/A very long option label/)).closest('span')!
expect(label.className).toContain('whitespace-pre-wrap')
expect(label.className).toContain('break-words')
expect(label.className).not.toContain('truncate')
})
it('FE-MOB-POLLS-029: the form uses textareas and keeps inner line breaks on submit', async () => {
const createPoll = vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: poll({ id: 9, question: 'Multi\nline?' }) })
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
const question = await screen.findByPlaceholderText('collab.polls.questionPlaceholder')
expect(question.tagName).toBe('TEXTAREA')
expect(screen.getByText('collab.polls.markdownHint')).toBeInTheDocument()
const option = screen.getByPlaceholderText('collab.polls.optionPlaceholder:1')
expect(option.tagName).toBe('TEXTAREA')
fireEvent.change(question, { target: { value: ' Multi\nline? ' } })
fireEvent.change(option, { target: { value: 'A' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, expect.objectContaining({
question: 'Multi\nline?',
options: ['A', 'B'],
})))
})
})
+1
View File
@@ -66,6 +66,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'اسأل المجموعة وصوّتوا معًا',
'collab.polls.question': 'السؤال',
'collab.polls.questionPlaceholder': 'ماذا ينبغي أن نفعل؟',
'collab.polls.markdownHint': 'يدعم تنسيق Markdown',
'collab.polls.addOption': '+ إضافة خيار',
'collab.polls.optionPlaceholder': 'الخيار {n}',
'collab.polls.create': 'إنشاء استطلاع',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Pergunte ao grupo e votem juntos',
'collab.polls.question': 'Pergunta',
'collab.polls.questionPlaceholder': 'O que vamos fazer?',
'collab.polls.markdownHint': 'Suporta Markdown',
'collab.polls.addOption': '+ Adicionar opção',
'collab.polls.optionPlaceholder': 'Opção {n}',
'collab.polls.create': 'Criar enquete',
+1
View File
@@ -59,6 +59,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Pregunta al grup i voteu plegats',
'collab.polls.question': 'Pregunta',
'collab.polls.questionPlaceholder': 'Què hauríem de fer?',
'collab.polls.markdownHint': 'Compatible amb Markdown',
'collab.polls.addOption': '+ Afegeix una opció',
'collab.polls.optionPlaceholder': 'Opció {n}',
'collab.polls.create': "Crea l'enquesta",
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Zeptejte se skupiny a hlasujte společně',
'collab.polls.question': 'Otázka',
'collab.polls.questionPlaceholder': 'Co bychom měli dělat?',
'collab.polls.markdownHint': 'Podporuje Markdown',
'collab.polls.addOption': '+ Přidat možnost',
'collab.polls.optionPlaceholder': 'Možnost {n}',
'collab.polls.create': 'Vytvořit hlasování',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
'collab.polls.question': 'Frage',
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
'collab.polls.markdownHint': 'Markdown wird unterstützt',
'collab.polls.addOption': '+ Option hinzufügen',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Umfrage erstellen',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Ask the group and vote together',
'collab.polls.question': 'Question',
'collab.polls.questionPlaceholder': 'What should we do?',
'collab.polls.markdownHint': 'Markdown supported',
'collab.polls.addOption': '+ Add option',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Create Poll',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos',
'collab.polls.question': 'Pregunta',
'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?',
'collab.polls.markdownHint': 'Compatible con Markdown',
'collab.polls.addOption': '+ Añadir opción',
'collab.polls.optionPlaceholder': 'Opción {n}',
'collab.polls.create': 'Crear encuesta',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Posez des questions au groupe et votez ensemble',
'collab.polls.question': 'Question',
'collab.polls.questionPlaceholder': 'Que devrait-on faire ?',
'collab.polls.markdownHint': 'Markdown pris en charge',
'collab.polls.addOption': '+ Ajouter une option',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Créer le sondage',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Ρωτήστε την ομάδα και ψηφίστε μαζί',
'collab.polls.question': 'Ερώτηση',
'collab.polls.questionPlaceholder': 'Τι πρέπει να κάνουμε;',
'collab.polls.markdownHint': 'Υποστηρίζεται Markdown',
'collab.polls.addOption': '+ Προσθήκη επιλογής',
'collab.polls.optionPlaceholder': 'Επιλογή {n}',
'collab.polls.create': 'Δημιουργία Δημοσκόπησης',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Kérdezd meg a csoportot és szavazzatok együtt',
'collab.polls.question': 'Kérdés',
'collab.polls.questionPlaceholder': 'Mit csináljunk?',
'collab.polls.markdownHint': 'Markdown támogatott',
'collab.polls.addOption': 'Opció hozzáadása',
'collab.polls.optionPlaceholder': '{n}. opció',
'collab.polls.create': 'Szavazás létrehozása',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Tanyakan ke grup dan voting bersama',
'collab.polls.question': 'Pertanyaan',
'collab.polls.questionPlaceholder': 'Apa yang harus kita lakukan?',
'collab.polls.markdownHint': 'Mendukung Markdown',
'collab.polls.addOption': '+ Tambah pilihan',
'collab.polls.optionPlaceholder': 'Pilihan {n}',
'collab.polls.create': 'Buat Polling',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Chiedi al gruppo e votate insieme',
'collab.polls.question': 'Domanda',
'collab.polls.questionPlaceholder': 'Cosa dovremmo fare?',
'collab.polls.markdownHint': 'Markdown supportato',
'collab.polls.addOption': '+ Aggiungi opzione',
'collab.polls.optionPlaceholder': 'Opzione {n}',
'collab.polls.create': 'Crea sondaggio',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': '質問してみんなで投票しましょう',
'collab.polls.question': '質問',
'collab.polls.questionPlaceholder': '何をしますか?',
'collab.polls.markdownHint': 'Markdown対応',
'collab.polls.addOption': '+ 選択肢を追加',
'collab.polls.optionPlaceholder': '選択肢 {n}',
'collab.polls.create': '投票を作成',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': '그룹에 질문하고 함께 투표하세요',
'collab.polls.question': '질문',
'collab.polls.questionPlaceholder': '무엇을 할까요?',
'collab.polls.markdownHint': 'Markdown 지원',
'collab.polls.addOption': '+ 옵션 추가',
'collab.polls.optionPlaceholder': '옵션 {n}',
'collab.polls.create': '투표 만들기',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
'collab.polls.question': 'Vraag',
'collab.polls.questionPlaceholder': 'Wat zullen we doen?',
'collab.polls.markdownHint': 'Markdown wordt ondersteund',
'collab.polls.addOption': '+ Optie toevoegen',
'collab.polls.optionPlaceholder': 'Optie {n}',
'collab.polls.create': 'Poll aanmaken',
+1
View File
@@ -66,6 +66,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Zapytaj grupę i głosujcie razem',
'collab.polls.question': 'Pytanie',
'collab.polls.questionPlaceholder': 'Co powinniśmy zrobić?',
'collab.polls.markdownHint': 'Obsługuje Markdown',
'collab.polls.addOption': '+ Dodaj opcję',
'collab.polls.optionPlaceholder': 'Opcja {n}',
'collab.polls.create': 'Utwórz ankietę',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Задайте вопрос группе и голосуйте вместе',
'collab.polls.question': 'Вопрос',
'collab.polls.questionPlaceholder': 'Что нам делать?',
'collab.polls.markdownHint': 'Поддерживается Markdown',
'collab.polls.addOption': '+ Добавить вариант',
'collab.polls.optionPlaceholder': 'Вариант {n}',
'collab.polls.create': 'Создать опрос',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Fråga gruppen och rösta tillsammans',
'collab.polls.question': 'Fråga',
'collab.polls.questionPlaceholder': 'Vad ska vi göra?',
'collab.polls.markdownHint': 'Markdown stöds',
'collab.polls.addOption': '+ Lägg till alternativ',
'collab.polls.optionPlaceholder': 'Alternativ {n}',
'collab.polls.create': 'Skapa omröstning',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Gruba sorun ve birlikte oy verin',
'collab.polls.question': 'Soru',
'collab.polls.questionPlaceholder': 'Ne yapalım?',
'collab.polls.markdownHint': 'Markdown desteklenir',
'collab.polls.addOption': '+ Seçenek ekle',
'collab.polls.optionPlaceholder': 'Seçenek {n}',
'collab.polls.create': 'Anket Oluştur',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Поставте питання групі і голосуйте разом',
'collab.polls.question': 'Питання',
'collab.polls.questionPlaceholder': 'Що нам робити?',
'collab.polls.markdownHint': 'Підтримується Markdown',
'collab.polls.addOption': '+ Додати варіант',
'collab.polls.optionPlaceholder': 'Варіант {n}',
'collab.polls.create': 'Створити опитування',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': 'Hỏi nhóm và cùng nhau bỏ phiếu',
'collab.polls.question': 'Câu hỏi',
'collab.polls.questionPlaceholder': 'Chúng ta nên làm gì?',
'collab.polls.markdownHint': 'Hỗ trợ Markdown',
'collab.polls.addOption': '+ Thêm tùy chọn',
'collab.polls.optionPlaceholder': 'Tùy chọn {n}',
'collab.polls.create': 'Tạo cuộc thăm dò ý kiến',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': '向團隊提問並一起投票',
'collab.polls.question': '問題',
'collab.polls.questionPlaceholder': '我們應該做什麼?',
'collab.polls.markdownHint': '支援 Markdown',
'collab.polls.addOption': '+ 新增選項',
'collab.polls.optionPlaceholder': '選項 {n}',
'collab.polls.create': '建立投票',
+1
View File
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
'collab.polls.emptyHint': '向团队提问并一起投票',
'collab.polls.question': '问题',
'collab.polls.questionPlaceholder': '我们应该做什么?',
'collab.polls.markdownHint': '支持 Markdown',
'collab.polls.addOption': '+ 添加选项',
'collab.polls.optionPlaceholder': '选项 {n}',
'collab.polls.create': '创建投票',