// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useState, useRef, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; import { v4 as generateUuid } from 'uuid'; import { tw } from '../axo/tw.dom.js'; import type { LocalizerType } from '../types/Util.std.js'; import { Modal } from './Modal.dom.js'; import { AutoSizeTextArea } from './AutoSizeTextArea.dom.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; import { AxoSwitch } from '../axo/AxoSwitch.dom.js'; import { Toast } from './Toast.dom.js'; import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.js'; import { FunEmojiPickerButton } from './fun/FunButton.dom.js'; import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js'; import { getEmojiVariantByKey } from './fun/data/emojis.std.js'; import { strictAssert } from '../util/assert.std.js'; import { type PollCreateType, POLL_QUESTION_MAX_LENGTH, POLL_OPTIONS_MIN_COUNT, POLL_OPTIONS_MAX_COUNT, } from '../types/Polls.dom.js'; import { count as countGraphemes } from '../util/grapheme.std.js'; import { MAX_MESSAGE_BODY_BYTE_LENGTH } from '../util/longAttachment.std.js'; type PollOption = { id: string; value: string; }; export type PollCreateModalProps = { i18n: LocalizerType; onClose: () => void; onSendPoll: (poll: PollCreateType) => void; }; export function PollCreateModal({ i18n, onClose, onSendPoll, }: PollCreateModalProps): JSX.Element { const [question, setQuestion] = useState(''); const [options, setOptions] = useState>([ { id: generateUuid(), value: '' }, { id: generateUuid(), value: '' }, ]); const [allowMultiple, setAllowMultiple] = useState(false); const [emojiPickerOpenForOption, setEmojiPickerOpenForOption] = useState< string | null >(null); const [validationErrors, setValidationErrors] = useState<{ question: boolean; options: boolean; }>({ question: false, options: false }); const [validationErrorMessages, setValidationErrorMessages] = useState | null>(null); const questionInputRef = useRef(null); const optionRefsMap = useRef>( new Map() ); const computeOptionsAfterChange = useCallback( ( updatedOptions: Array, changedOptionId: string ): { options: Array; removedIndex?: number } => { const resultOptions = [...updatedOptions]; const changedIndex = resultOptions.findIndex( opt => opt.id === changedOptionId ); const isLastOption = changedIndex === resultOptions.length - 1; const isSecondToLast = changedIndex === resultOptions.length - 2; const changedOption = resultOptions[changedIndex]; const hasText = changedOption?.value.trim().length > 0; const canAddMore = resultOptions.length < POLL_OPTIONS_MAX_COUNT; const canRemove = resultOptions.length > POLL_OPTIONS_MIN_COUNT; let removedIndex: number | undefined; // Add new empty option when typing in the last option if (isLastOption && hasText && canAddMore) { resultOptions.push({ id: generateUuid(), value: '' }); } // Remove the last option if second-to-last becomes empty and last is also empty if (isSecondToLast && !hasText && canRemove) { const lastOption = resultOptions[resultOptions.length - 1]; const lastOptionEmpty = !lastOption?.value.trim(); if (lastOptionEmpty) { resultOptions.pop(); removedIndex = resultOptions.length; } } // Remove middle empty options if (!isLastOption && !hasText && canRemove) { resultOptions.splice(changedIndex, 1); removedIndex = changedIndex; // Ensure there's always an empty option at the end const lastOption = resultOptions[resultOptions.length - 1]; const lastOptionEmpty = !lastOption || !lastOption.value.trim(); if (!lastOptionEmpty && resultOptions.length < POLL_OPTIONS_MAX_COUNT) { resultOptions.push({ id: generateUuid(), value: '' }); } } return { options: resultOptions, removedIndex }; }, [] ); const handleQuestionChange = useCallback( (value: string) => { setQuestion(value); if (validationErrors.question || validationErrors.options) { setValidationErrors({ question: false, options: false }); } }, [validationErrors] ); const handleOptionChange = useCallback( (id: string, value: string) => { const updatedOptions = options.map(opt => opt.id === id ? { ...opt, value } : opt ); const result = computeOptionsAfterChange(updatedOptions, id); flushSync(() => { setOptions(result.options); }); // Handle focus management if an option was removed if (result.removedIndex !== undefined) { const focusIndex = Math.min( result.removedIndex, result.options.length - 1 ); const targetOption = result.options[focusIndex]; if (targetOption) { optionRefsMap.current.get(targetOption.id)?.focus(); } } if (validationErrors.question || validationErrors.options) { setValidationErrors({ question: false, options: false }); } }, [computeOptionsAfterChange, validationErrors, options] ); const handleEnterKey = useCallback( (event: React.KeyboardEvent, currentIndex: number) => { event.preventDefault(); const nextOption = options[currentIndex + 1]; if (nextOption) { optionRefsMap.current.get(nextOption.id)?.focus(); } }, [options] ); const handleSelectEmoji = useCallback( (optionId: string, emojiSelection: FunEmojiSelection) => { const inputEl = optionRefsMap.current.get(optionId); strictAssert(inputEl, 'Missing input ref for option'); const { selectionStart, selectionEnd } = inputEl; const variant = getEmojiVariantByKey(emojiSelection.variantKey); const emoji = variant.value; const updatedOptions = options.map(opt => { if (opt.id !== optionId) { return opt; } let newValue: string; if (selectionStart == null || selectionEnd == null) { newValue = `${opt.value}${emoji}`; } else { const before = opt.value.slice(0, selectionStart); const after = opt.value.slice(selectionEnd); newValue = `${before}${emoji}${after}`; } // Don't insert if it would exceed the max grapheme length if (countGraphemes(newValue) > POLL_QUESTION_MAX_LENGTH) { return opt; // Return unchanged } return { ...opt, value: newValue }; }); const result = computeOptionsAfterChange(updatedOptions, optionId); setOptions(result.options); }, [computeOptionsAfterChange, options] ); const allowSend = useMemo(() => { if (question.trim()) { return true; } return options.some(opt => opt.value.trim()); }, [question, options]); const validatePoll = useCallback((): { errors: Array; hasQuestionError: boolean; hasOptionsError: boolean; } => { const questionErrors: Array = []; const optionErrors: Array = []; const questionValue = question.trim(); if (!questionValue) { questionErrors.push(i18n('icu:PollCreateModal__Error--RequiresQuestion')); } if (Buffer.byteLength(questionValue) > MAX_MESSAGE_BODY_BYTE_LENGTH) { questionErrors.push(i18n('icu:PollCreateModal__Error--QuestionTooLong')); } const optionValues = options.map(opt => opt.value.trim()); const nonEmptyOptions = optionValues.filter(value => value); if (nonEmptyOptions.length < POLL_OPTIONS_MIN_COUNT) { optionErrors.push(i18n('icu:PollCreateModal__Error--RequiresTwoOptions')); } const optionOverByteLength = optionValues.find( value => Buffer.byteLength(value) > MAX_MESSAGE_BODY_BYTE_LENGTH ); if (optionOverByteLength) { optionErrors.push(i18n('icu:PollCreateModal__Error--OptionTooLong')); } return { errors: questionErrors.concat(optionErrors), hasQuestionError: questionErrors.length > 0, hasOptionsError: optionErrors.length > 0, }; }, [question, options, i18n]); const handleSend = useCallback(() => { const validation = validatePoll(); if (validation.errors.length > 0) { // Set validation error state for aria-invalid setValidationErrors({ question: validation.hasQuestionError, options: validation.hasOptionsError, }); // Show local toast with errors setValidationErrorMessages(validation.errors); // Focus the first invalid field if (validation.hasQuestionError) { questionInputRef.current?.focus(); } else if (validation.hasOptionsError) { // Find first empty option or just focus the first option const firstEmptyOption = options.find(opt => !opt.value.trim()); const targetOptionId = firstEmptyOption?.id ?? options[0]?.id; if (targetOptionId) { optionRefsMap.current.get(targetOptionId)?.focus(); } } return; } const nonEmptyOptions = options .map(opt => opt.value.trim()) .filter(value => value.length > 0); const poll: PollCreateType = { question: question.trim(), options: nonEmptyOptions, allowMultiple, }; onSendPoll(poll); }, [validatePoll, question, options, allowMultiple, onSendPoll]); return ( {/* Visually hidden error messages for screen readers */}
{i18n('icu:PollCreateModal__Error--RequiresQuestion')}
{i18n('icu:PollCreateModal__Error--RequiresTwoOptions')}
{i18n('icu:PollCreateModal__questionLabel')}
{i18n('icu:PollCreateModal__optionsLabel')}
{options.map((option, index) => (
optionRefsMap.current.set(option.id, el)} i18n={i18n} moduleClassName="PollCreateModalInput" value={option.value} onChange={value => handleOptionChange(option.id, value)} onEnter={e => handleEnterKey(e, index)} placeholder={i18n('icu:PollCreateModal__optionPlaceholder', { number: String(index + 1), })} maxLengthCount={POLL_QUESTION_MAX_LENGTH} whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30} aria-invalid={validationErrors.options || undefined} aria-errormessage={ validationErrors.options ? 'poll-options-error' : undefined } > { setEmojiPickerOpenForOption(open ? option.id : null); }} onSelectEmoji={emojiSelection => handleSelectEmoji(option.id, emojiSelection) } closeOnSelect >
))}
{validationErrorMessages && ( )}
{i18n('icu:cancel')} {i18n('icu:PollCreateModal__sendButton')}
); }