diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 654f789524..b6d250090f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1498,6 +1498,42 @@ "messageformat": "No votes", "description": "Message shown when poll has no votes" }, + "icu:PollCreateModal__title": { + "messageformat": "New poll", + "description": "Title for the modal to create a new poll" + }, + "icu:PollCreateModal__questionLabel": { + "messageformat": "Question", + "description": "Label for the poll question input field" + }, + "icu:PollCreateModal__questionPlaceholder": { + "messageformat": "What should we order for lunch?", + "description": "Placeholder text for the poll question input field" + }, + "icu:PollCreateModal__optionsLabel": { + "messageformat": "Options", + "description": "Label for the poll options section" + }, + "icu:PollCreateModal__optionPlaceholder": { + "messageformat": "Option {number}", + "description": "Placeholder text for poll option input field" + }, + "icu:PollCreateModal__allowMultipleVotes": { + "messageformat": "Allow multiple votes", + "description": "Label for the toggle to allow multiple votes in a poll" + }, + "icu:PollCreateModal__sendButton": { + "messageformat": "Send", + "description": "Send button text in poll creation modal" + }, + "icu:PollCreateModal__Error--RequiresQuestion": { + "messageformat": "Poll question is required", + "description": "Error message shown when poll question is empty" + }, + "icu:PollCreateModal__Error--RequiresTwoOptions": { + "messageformat": "Poll requires at least 2 options", + "description": "Error message shown when poll has fewer than 2 non-empty options" + }, "icu:deleteConversation": { "messageformat": "Delete", "description": "Menu item for deleting a conversation (including messages), title case." @@ -5828,6 +5864,22 @@ "messageformat": "Attach file", "description": "Aria label for file attachment button in composition area" }, + "icu:CompositionArea__AttachMenu__PhotosAndVideos": { + "messageformat": "Photos & videos", + "description": "Menu item to attach photos and videos" + }, + "icu:CompositionArea__AttachMenu__File": { + "messageformat": "File", + "description": "Menu item to attach a file" + }, + "icu:CompositionArea__AttachMenu__Poll": { + "messageformat": "Poll", + "description": "Menu item to create a poll" + }, + "icu:CompositionArea--attach-plus": { + "messageformat": "Add attachment or poll", + "description": "Aria label for plus button that opens attachment menu" + }, "icu:CompositionArea--sms-only__title": { "messageformat": "This person isn’t using Signal", "description": "Title for the composition area for the SMS-only contact" diff --git a/stylesheets/components/PollCreateModal.scss b/stylesheets/components/PollCreateModal.scss new file mode 100644 index 0000000000..c042291163 --- /dev/null +++ b/stylesheets/components/PollCreateModal.scss @@ -0,0 +1,8 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.PollCreateModalInput { + &__container { + margin-block: 0; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 4574cb5e6f..d8066a39ad 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -150,6 +150,7 @@ @use 'components/PermissionsPopup.scss'; @use 'components/PlaybackButton.scss'; @use 'components/PlaybackRateButton.scss'; +@use 'components/PollCreateModal.scss'; @use 'components/Preferences.scss'; @use 'components/PreferencesDonations.scss'; @use 'components/ProfileEditor.scss'; diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index db4f2d3564..baabb6daf4 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -76,6 +76,12 @@ import { strictAssert } from '../util/assert.std.js'; import { ConfirmationDialog } from './ConfirmationDialog.dom.js'; import type { EmojiSkinTone } from './fun/data/emojis.std.js'; import { FunPickerButton } from './fun/FunButton.dom.js'; +import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.js'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.js'; +import { AxoButton } from '../axo/AxoButton.dom.js'; +import { tw } from '../axo/tw.dom.js'; +import { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js'; +import { PollCreateModal } from './PollCreateModal.dom.js'; export type OwnProps = Readonly<{ acceptedMessageRequest: boolean | null; @@ -160,6 +166,7 @@ export type OwnProps = Readonly<{ voiceNoteAttachment?: InMemoryAttachmentDraftType; } ): unknown; + sendPoll(conversationId: string, poll: PollCreateType): unknown; quotedMessageId: string | null; quotedMessageProps: null | ReadonlyDeep< Omit< @@ -244,6 +251,7 @@ export const CompositionArea = memo(function CompositionArea({ removeAttachment, sendEditedMessage, sendMultiMediaMessage, + sendPoll, setComposerFocus, setMessageToEdit, setQuoteByMessageId, @@ -332,8 +340,10 @@ export const CompositionArea = memo(function CompositionArea({ const [attachmentToEdit, setAttachmentToEdit] = useState< AttachmentDraftType | undefined >(); + const [isPollModalOpen, setIsPollModalOpen] = useState(false); const inputApiRef = useRef(); const fileInputRef = useRef(null); + const photoVideoInputRef = useRef(null); const handleForceSend = useCallback(() => { setLarge(false); @@ -407,8 +417,9 @@ export const CompositionArea = memo(function CompositionArea({ ] ); - const launchAttachmentPicker = useCallback(() => { - const fileInput = fileInputRef.current; + const launchAttachmentPicker = useCallback((type?: 'media' | 'file') => { + const inputRef = type === 'media' ? photoVideoInputRef : fileInputRef; + const fileInput = inputRef.current; if (fileInput) { // Setting the value to empty so that onChange always fires in case // you add multiple photos. @@ -417,6 +428,32 @@ export const CompositionArea = memo(function CompositionArea({ } }, []); + const launchMediaPicker = useCallback( + () => launchAttachmentPicker('media'), + [launchAttachmentPicker] + ); + + const launchFilePicker = useCallback( + () => launchAttachmentPicker('file'), + [launchAttachmentPicker] + ); + + const handleOpenPollModal = useCallback(() => { + setIsPollModalOpen(true); + }, []); + + const handleClosePollModal = useCallback(() => { + setIsPollModalOpen(false); + }, []); + + const handleSendPoll = useCallback( + (poll: PollCreateType) => { + sendPoll(conversationId, poll); + handleClosePollModal(); + }, + [conversationId, sendPoll, handleClosePollModal] + ); + function maybeEditAttachment(attachment: AttachmentDraftType) { if (!isImageTypeSupported(attachment.contentType)) { return; @@ -444,7 +481,7 @@ export const CompositionArea = memo(function CompositionArea({ const [hasFocus, setHasFocus] = useState(false); - const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker); + const attachFileShortcut = useAttachFileShortcut(launchFilePicker); const editLastMessageSent = useEditLastMessageSent(maybeEditMessage); useKeyboardShortcutsConditionally( hasFocus, @@ -708,17 +745,56 @@ export const CompositionArea = memo(function CompositionArea({ ) : null; const isRecording = recordingState === RecordingState.Recording; - const attButton = - draftEditMessage || linkPreviewResult || isRecording ? undefined : ( + + let attButton; + if (draftEditMessage || linkPreviewResult || isRecording) { + attButton = undefined; + } else if (isPollSendEnabled()) { + attButton = ( +
+ + +
+ + + +
+
+ + + {i18n('icu:CompositionArea__AttachMenu__PhotosAndVideos')} + + + {i18n('icu:CompositionArea__AttachMenu__File')} + + {conversationType === 'group' && ( + + {i18n('icu:CompositionArea__AttachMenu__Poll')} + + )} + +
+
+ ); + } else { + attButton = (
); + } const sendButtonFragment = !draftEditMessage ? ( <> @@ -1049,7 +1125,7 @@ export const CompositionArea = memo(function CompositionArea({ attachments={draftAttachments} canEditImages i18n={i18n} - onAddAttachment={launchAttachmentPicker} + onAddAttachment={launchFilePicker} onClickAttachment={maybeEditAttachment} onClose={() => onClearAttachments(conversationId)} onCloseAttachment={attachment => { @@ -1133,6 +1209,22 @@ export const CompositionArea = memo(function CompositionArea({ processAttachments={processAttachments} ref={fileInputRef} /> + + {isPollModalOpen && ( + + )} ); }); diff --git a/ts/components/CompositionUpload.dom.tsx b/ts/components/CompositionUpload.dom.tsx index b0b62086d8..8a7b1e814c 100644 --- a/ts/components/CompositionUpload.dom.tsx +++ b/ts/components/CompositionUpload.dom.tsx @@ -25,11 +25,19 @@ export type PropsType = { files: ReadonlyArray; flags: number | null; }) => unknown; + acceptMediaOnly?: boolean; + testId?: string; }; export const CompositionUpload = forwardRef( function CompositionUploadInner( - { conversationId, draftAttachments, processAttachments }, + { + conversationId, + draftAttachments, + processAttachments, + acceptMediaOnly, + testId, + }, ref ) { const onFileInputChange: ChangeEventHandler< @@ -48,13 +56,14 @@ export const CompositionUpload = forwardRef( return isImageAttachment(attachment) || isVideoAttachment(attachment); }); - const acceptContentTypes = anyVideoOrImageAttachments - ? [...getSupportedImageTypes(), ...getSupportedVideoTypes()] - : null; + const acceptContentTypes = + acceptMediaOnly || anyVideoOrImageAttachments + ? [...getSupportedImageTypes(), ...getSupportedVideoTypes()] + : null; return ( unknown; onBlur?: () => unknown; onFocus?: () => unknown; - onEnter?: () => unknown; + onEnter?: (event: KeyboardEvent) => unknown; placeholder: string; readOnly?: boolean; value?: string; whenToShowRemainingCount?: number; whenToWarnRemainingCount?: number; children?: ReactNode; + 'aria-invalid'?: boolean | 'true' | 'false'; + 'aria-errormessage'?: string; }; /** @@ -90,6 +92,8 @@ export const Input = forwardRef< whenToShowRemainingCount = Infinity, whenToWarnRemainingCount = Infinity, children, + 'aria-invalid': ariaInvalid, + 'aria-errormessage': ariaErrorMessage, }, ref ) { @@ -120,7 +124,7 @@ export const Input = forwardRef< const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (onEnter && event.key === 'Enter') { - onEnter(); + onEnter(event); } const inputEl = innerRef.current; @@ -235,6 +239,8 @@ export const Input = forwardRef< ), type: 'text', value, + 'aria-invalid': ariaInvalid, + 'aria-errormessage': ariaErrorMessage, }; const clearButtonElement = diff --git a/ts/components/PollCreateModal.dom.stories.tsx b/ts/components/PollCreateModal.dom.stories.tsx new file mode 100644 index 0000000000..a6ec2aa79c --- /dev/null +++ b/ts/components/PollCreateModal.dom.stories.tsx @@ -0,0 +1,23 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import type { PollCreateModalProps } from './PollCreateModal.dom.js'; +import { PollCreateModal } from './PollCreateModal.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/PollCreateModal', +} satisfies Meta; + +const onClose = action('onClose'); +const onSendPoll = action('onSendPoll'); + +export function Default(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/PollCreateModal.dom.tsx b/ts/components/PollCreateModal.dom.tsx new file mode 100644 index 0000000000..93c28d7a52 --- /dev/null +++ b/ts/components/PollCreateModal.dom.tsx @@ -0,0 +1,393 @@ +// 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'; + +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 errors: Array = []; + const hasQuestionError = !question.trim(); + const nonEmptyOptions = options.filter(opt => opt.value.trim()); + const hasOptionsError = nonEmptyOptions.length < POLL_OPTIONS_MIN_COUNT; + + if (hasQuestionError) { + errors.push(i18n('icu:PollCreateModal__Error--RequiresQuestion')); + } + + if (hasOptionsError) { + errors.push(i18n('icu:PollCreateModal__Error--RequiresTwoOptions')); + } + + return { errors, hasQuestionError, hasOptionsError }; + }, [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')} + +
+
+ + ); +} diff --git a/ts/state/ducks/composer.preload.ts b/ts/state/ducks/composer.preload.ts index 656a0b0d19..77301b6b67 100644 --- a/ts/state/ducks/composer.preload.ts +++ b/ts/state/ducks/composer.preload.ts @@ -30,6 +30,8 @@ import type { ShowToastActionType } from './toast.preload.js'; import type { StateType as RootStateType } from '../reducer.preload.js'; import { createLogger } from '../../logging/log.std.js'; import * as Errors from '../../types/errors.std.js'; +import type { PollCreateType } from '../../types/Polls.dom.js'; +import { enqueuePollCreateForSend } from '../../util/enqueuePollCreateForSend.dom.js'; import { ADD_PREVIEW as ADD_LINK_PREVIEW, REMOVE_PREVIEW as REMOVE_LINK_PREVIEW, @@ -254,6 +256,7 @@ export const actions = { scrollToQuotedMessage, sendEditedMessage, sendMultiMediaMessage, + sendPoll, sendStickerMessage, setComposerFocus, setMediaQualitySetting, @@ -694,6 +697,55 @@ function sendStickerMessage( }; } +function sendPoll( + conversationId: string, + poll: PollCreateType +): ThunkAction< + void, + RootStateType, + unknown, + NoopActionType | ShowToastActionType +> { + return async dispatch => { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('sendPoll: No conversation found'); + } + + const recipientsByConversation = getRecipientsByConversation([ + conversation.attributes, + ]); + + try { + const sendAnyway = await blockSendUntilConversationsAreVerified( + recipientsByConversation, + SafetyNumberChangeSource.MessageSend + ); + if (!sendAnyway) { + return; + } + + const toast = shouldShowInvalidMessageToast(conversation.attributes); + if (toast != null) { + dispatch({ + type: SHOW_TOAST, + payload: toast, + }); + return; + } + + await enqueuePollCreateForSend(conversation, poll); + } catch (error) { + log.error('sendPoll error:', Errors.toLogFormat(error)); + } + + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + // Not cool that we have to pull from ConversationModel here // but if the current selected conversation isn't the one that we're operating // on then we won't be able to grab attachments from state so we resort to the diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx index e2777d3f12..94b4a479f4 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -194,6 +194,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ sendStickerMessage, sendEditedMessage, sendMultiMediaMessage, + sendPoll, setComposerFocus, } = useComposerActions(); const { @@ -341,6 +342,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ sendStickerMessage={sendStickerMessage} sendEditedMessage={sendEditedMessage} sendMultiMediaMessage={sendMultiMediaMessage} + sendPoll={sendPoll} scrollToMessage={scrollToMessage} setComposerFocus={setComposerFocus} setMessageToEdit={setMessageToEdit} diff --git a/ts/types/Polls.dom.ts b/ts/types/Polls.dom.ts index 7613c1b68e..a11f0e189e 100644 --- a/ts/types/Polls.dom.ts +++ b/ts/types/Polls.dom.ts @@ -13,6 +13,10 @@ import { isAlpha, isBeta, isProduction } from '../util/version.std.js'; import type { SendStateByConversationId } from '../messages/MessageSendState.std.js'; import { aciSchema } from './ServiceId.std.js'; +export const POLL_QUESTION_MAX_LENGTH = 100; +export const POLL_OPTIONS_MIN_COUNT = 2; +export const POLL_OPTIONS_MAX_COUNT = 10; + // PollCreate schema (processed shape) // - question: required, 1..100 chars // - options: required, 2..10 items; each 1..100 chars @@ -22,20 +26,23 @@ export const PollCreateSchema = z question: z .string() .min(1) - .refine(value => hasAtMostGraphemes(value, 100), { - message: 'question must contain at most 100 characters', + .refine(value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH), { + message: `question must contain at most ${POLL_QUESTION_MAX_LENGTH} characters`, }), options: z .array( z .string() .min(1) - .refine(value => hasAtMostGraphemes(value, 100), { - message: 'option must contain at most 100 characters', - }) + .refine( + value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH), + { + message: `option must contain at most ${POLL_QUESTION_MAX_LENGTH} characters`, + } + ) ) - .min(2) - .max(10) + .min(POLL_OPTIONS_MIN_COUNT) + .max(POLL_OPTIONS_MAX_COUNT) .readonly(), allowMultiple: z.boolean().optional(), }) diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 16d7d7562d..e4aecf30b0 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2327,5 +2327,29 @@ "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CompositionArea.dom.tsx", + "line": " const photoVideoInputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-10-27T16:28:11.852Z", + "reasonDetail": "Ref for photo/video file input element" + }, + { + "rule": "React-useRef", + "path": "ts/components/PollCreateModal.dom.tsx", + "line": " const questionInputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-11-02T17:27:24.705Z", + "reasonDetail": "Ref for question input to manage focus on validation errors" + }, + { + "rule": "React-useRef", + "path": "ts/components/PollCreateModal.dom.tsx", + "line": " const optionRefsMap = useRef>(", + "reasonCategory": "usageTrusted", + "updated": "2025-11-02T17:27:24.705Z", + "reasonDetail": "Map of refs for poll option inputs to manage focus" } ]