Poll create modal

This commit is contained in:
yash-signal
2025-11-03 15:03:11 -06:00
committed by GitHub
parent 4436184f95
commit 612aa2b8c8
12 changed files with 690 additions and 21 deletions

View File

@@ -1498,6 +1498,42 @@
"messageformat": "No votes", "messageformat": "No votes",
"description": "Message shown when poll has 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": { "icu:deleteConversation": {
"messageformat": "Delete", "messageformat": "Delete",
"description": "Menu item for deleting a conversation (including messages), title case." "description": "Menu item for deleting a conversation (including messages), title case."
@@ -5828,6 +5864,22 @@
"messageformat": "Attach file", "messageformat": "Attach file",
"description": "Aria label for file attachment button in composition area" "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": { "icu:CompositionArea--sms-only__title": {
"messageformat": "This person isnt using Signal", "messageformat": "This person isnt using Signal",
"description": "Title for the composition area for the SMS-only contact" "description": "Title for the composition area for the SMS-only contact"

View File

@@ -0,0 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.PollCreateModalInput {
&__container {
margin-block: 0;
}
}

View File

@@ -150,6 +150,7 @@
@use 'components/PermissionsPopup.scss'; @use 'components/PermissionsPopup.scss';
@use 'components/PlaybackButton.scss'; @use 'components/PlaybackButton.scss';
@use 'components/PlaybackRateButton.scss'; @use 'components/PlaybackRateButton.scss';
@use 'components/PollCreateModal.scss';
@use 'components/Preferences.scss'; @use 'components/Preferences.scss';
@use 'components/PreferencesDonations.scss'; @use 'components/PreferencesDonations.scss';
@use 'components/ProfileEditor.scss'; @use 'components/ProfileEditor.scss';

View File

@@ -76,6 +76,12 @@ import { strictAssert } from '../util/assert.std.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js'; import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import type { EmojiSkinTone } from './fun/data/emojis.std.js'; import type { EmojiSkinTone } from './fun/data/emojis.std.js';
import { FunPickerButton } from './fun/FunButton.dom.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<{ export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null; acceptedMessageRequest: boolean | null;
@@ -160,6 +166,7 @@ export type OwnProps = Readonly<{
voiceNoteAttachment?: InMemoryAttachmentDraftType; voiceNoteAttachment?: InMemoryAttachmentDraftType;
} }
): unknown; ): unknown;
sendPoll(conversationId: string, poll: PollCreateType): unknown;
quotedMessageId: string | null; quotedMessageId: string | null;
quotedMessageProps: null | ReadonlyDeep< quotedMessageProps: null | ReadonlyDeep<
Omit< Omit<
@@ -244,6 +251,7 @@ export const CompositionArea = memo(function CompositionArea({
removeAttachment, removeAttachment,
sendEditedMessage, sendEditedMessage,
sendMultiMediaMessage, sendMultiMediaMessage,
sendPoll,
setComposerFocus, setComposerFocus,
setMessageToEdit, setMessageToEdit,
setQuoteByMessageId, setQuoteByMessageId,
@@ -332,8 +340,10 @@ export const CompositionArea = memo(function CompositionArea({
const [attachmentToEdit, setAttachmentToEdit] = useState< const [attachmentToEdit, setAttachmentToEdit] = useState<
AttachmentDraftType | undefined AttachmentDraftType | undefined
>(); >();
const [isPollModalOpen, setIsPollModalOpen] = useState(false);
const inputApiRef = useRef<InputApi | undefined>(); const inputApiRef = useRef<InputApi | undefined>();
const fileInputRef = useRef<null | HTMLInputElement>(null); const fileInputRef = useRef<null | HTMLInputElement>(null);
const photoVideoInputRef = useRef<null | HTMLInputElement>(null);
const handleForceSend = useCallback(() => { const handleForceSend = useCallback(() => {
setLarge(false); setLarge(false);
@@ -407,8 +417,9 @@ export const CompositionArea = memo(function CompositionArea({
] ]
); );
const launchAttachmentPicker = useCallback(() => { const launchAttachmentPicker = useCallback((type?: 'media' | 'file') => {
const fileInput = fileInputRef.current; const inputRef = type === 'media' ? photoVideoInputRef : fileInputRef;
const fileInput = inputRef.current;
if (fileInput) { if (fileInput) {
// Setting the value to empty so that onChange always fires in case // Setting the value to empty so that onChange always fires in case
// you add multiple photos. // 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) { function maybeEditAttachment(attachment: AttachmentDraftType) {
if (!isImageTypeSupported(attachment.contentType)) { if (!isImageTypeSupported(attachment.contentType)) {
return; return;
@@ -444,7 +481,7 @@ export const CompositionArea = memo(function CompositionArea({
const [hasFocus, setHasFocus] = useState(false); const [hasFocus, setHasFocus] = useState(false);
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker); const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage); const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
useKeyboardShortcutsConditionally( useKeyboardShortcutsConditionally(
hasFocus, hasFocus,
@@ -708,17 +745,56 @@ export const CompositionArea = memo(function CompositionArea({
) : null; ) : null;
const isRecording = recordingState === RecordingState.Recording; const isRecording = recordingState === RecordingState.Recording;
const attButton =
draftEditMessage || linkPreviewResult || isRecording ? undefined : ( let attButton;
if (draftEditMessage || linkPreviewResult || isRecording) {
attButton = undefined;
} else if (isPollSendEnabled()) {
attButton = (
<div className="CompositionArea__button-cell">
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<div className={tw('flex h-8 items-center')}>
<AxoButton.Root
variant="borderless-secondary"
size="small"
aria-label={i18n('icu:CompositionArea--attach-plus')}
>
<AxoSymbol.Icon label={null} symbol="plus" size={20} />
</AxoButton.Root>
</div>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item symbol="photo" onSelect={launchMediaPicker}>
{i18n('icu:CompositionArea__AttachMenu__PhotosAndVideos')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item symbol="file" onSelect={launchFilePicker}>
{i18n('icu:CompositionArea__AttachMenu__File')}
</AxoDropdownMenu.Item>
{conversationType === 'group' && (
<AxoDropdownMenu.Item
symbol="poll"
onSelect={handleOpenPollModal}
>
{i18n('icu:CompositionArea__AttachMenu__Poll')}
</AxoDropdownMenu.Item>
)}
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</div>
);
} else {
attButton = (
<div className="CompositionArea__button-cell"> <div className="CompositionArea__button-cell">
<button <button
type="button" type="button"
className="CompositionArea__attach-file" className="CompositionArea__attach-file"
onClick={launchAttachmentPicker} onClick={launchFilePicker}
aria-label={i18n('icu:CompositionArea--attach-file')} aria-label={i18n('icu:CompositionArea--attach-file')}
/> />
</div> </div>
); );
}
const sendButtonFragment = !draftEditMessage ? ( const sendButtonFragment = !draftEditMessage ? (
<> <>
@@ -1049,7 +1125,7 @@ export const CompositionArea = memo(function CompositionArea({
attachments={draftAttachments} attachments={draftAttachments}
canEditImages canEditImages
i18n={i18n} i18n={i18n}
onAddAttachment={launchAttachmentPicker} onAddAttachment={launchFilePicker}
onClickAttachment={maybeEditAttachment} onClickAttachment={maybeEditAttachment}
onClose={() => onClearAttachments(conversationId)} onClose={() => onClearAttachments(conversationId)}
onCloseAttachment={attachment => { onCloseAttachment={attachment => {
@@ -1133,6 +1209,22 @@ export const CompositionArea = memo(function CompositionArea({
processAttachments={processAttachments} processAttachments={processAttachments}
ref={fileInputRef} ref={fileInputRef}
/> />
<CompositionUpload
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
processAttachments={processAttachments}
ref={photoVideoInputRef}
acceptMediaOnly
testId="attachfile-input-media"
/>
{isPollModalOpen && (
<PollCreateModal
i18n={i18n}
onClose={handleClosePollModal}
onSendPoll={handleSendPoll}
/>
)}
</div> </div>
); );
}); });

View File

@@ -25,11 +25,19 @@ export type PropsType = {
files: ReadonlyArray<File>; files: ReadonlyArray<File>;
flags: number | null; flags: number | null;
}) => unknown; }) => unknown;
acceptMediaOnly?: boolean;
testId?: string;
}; };
export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>( export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
function CompositionUploadInner( function CompositionUploadInner(
{ conversationId, draftAttachments, processAttachments }, {
conversationId,
draftAttachments,
processAttachments,
acceptMediaOnly,
testId,
},
ref ref
) { ) {
const onFileInputChange: ChangeEventHandler< const onFileInputChange: ChangeEventHandler<
@@ -48,13 +56,14 @@ export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
return isImageAttachment(attachment) || isVideoAttachment(attachment); return isImageAttachment(attachment) || isVideoAttachment(attachment);
}); });
const acceptContentTypes = anyVideoOrImageAttachments const acceptContentTypes =
acceptMediaOnly || anyVideoOrImageAttachments
? [...getSupportedImageTypes(), ...getSupportedVideoTypes()] ? [...getSupportedImageTypes(), ...getSupportedVideoTypes()]
: null; : null;
return ( return (
<input <input
data-testid="attachfile-input" data-testid={testId ?? 'attachfile-input'}
hidden hidden
multiple multiple
onChange={onFileInputChange} onChange={onFileInputChange}

View File

@@ -35,13 +35,15 @@ export type PropsType = {
onChange: (value: string) => unknown; onChange: (value: string) => unknown;
onBlur?: () => unknown; onBlur?: () => unknown;
onFocus?: () => unknown; onFocus?: () => unknown;
onEnter?: () => unknown; onEnter?: (event: KeyboardEvent) => unknown;
placeholder: string; placeholder: string;
readOnly?: boolean; readOnly?: boolean;
value?: string; value?: string;
whenToShowRemainingCount?: number; whenToShowRemainingCount?: number;
whenToWarnRemainingCount?: number; whenToWarnRemainingCount?: number;
children?: ReactNode; children?: ReactNode;
'aria-invalid'?: boolean | 'true' | 'false';
'aria-errormessage'?: string;
}; };
/** /**
@@ -90,6 +92,8 @@ export const Input = forwardRef<
whenToShowRemainingCount = Infinity, whenToShowRemainingCount = Infinity,
whenToWarnRemainingCount = Infinity, whenToWarnRemainingCount = Infinity,
children, children,
'aria-invalid': ariaInvalid,
'aria-errormessage': ariaErrorMessage,
}, },
ref ref
) { ) {
@@ -120,7 +124,7 @@ export const Input = forwardRef<
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (onEnter && event.key === 'Enter') { if (onEnter && event.key === 'Enter') {
onEnter(); onEnter(event);
} }
const inputEl = innerRef.current; const inputEl = innerRef.current;
@@ -235,6 +239,8 @@ export const Input = forwardRef<
), ),
type: 'text', type: 'text',
value, value,
'aria-invalid': ariaInvalid,
'aria-errormessage': ariaErrorMessage,
}; };
const clearButtonElement = const clearButtonElement =

View File

@@ -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<PollCreateModalProps>;
const onClose = action('onClose');
const onSendPoll = action('onSendPoll');
export function Default(): JSX.Element {
return (
<PollCreateModal i18n={i18n} onClose={onClose} onSendPoll={onSendPoll} />
);
}

View File

@@ -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<Array<PollOption>>([
{ 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<Array<string> | null>(null);
const questionInputRef = useRef<HTMLTextAreaElement | null>(null);
const optionRefsMap = useRef<Map<string, HTMLTextAreaElement | null>>(
new Map()
);
const computeOptionsAfterChange = useCallback(
(
updatedOptions: Array<PollOption>,
changedOptionId: string
): { options: Array<PollOption>; 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<string>;
hasQuestionError: boolean;
hasOptionsError: boolean;
} => {
const errors: Array<string> = [];
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 (
<Modal
modalName="PollCreateModal"
i18n={i18n}
title={i18n('icu:PollCreateModal__title')}
hasXButton
onClose={onClose}
noMouseClose
>
{/* Visually hidden error messages for screen readers */}
<div id="poll-question-error" className={tw('sr-only')}>
{i18n('icu:PollCreateModal__Error--RequiresQuestion')}
</div>
<div id="poll-options-error" className={tw('sr-only')}>
{i18n('icu:PollCreateModal__Error--RequiresTwoOptions')}
</div>
<div className={tw('flex flex-col')}>
<div className={tw('ms-2 mt-4')}>
<div className={tw('type-body-medium font-semibold')}>
{i18n('icu:PollCreateModal__questionLabel')}
</div>
<div className={tw('mt-5')}>
<AutoSizeTextArea
ref={questionInputRef}
i18n={i18n}
moduleClassName="PollCreateModalInput"
value={question}
onChange={handleQuestionChange}
placeholder={i18n('icu:PollCreateModal__questionPlaceholder')}
maxLengthCount={POLL_QUESTION_MAX_LENGTH}
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30}
aria-invalid={validationErrors.question || undefined}
aria-errormessage={
validationErrors.question ? 'poll-question-error' : undefined
}
/>
</div>
<div className={tw('mt-5 type-body-medium font-semibold')}>
{i18n('icu:PollCreateModal__optionsLabel')}
</div>
<div className={tw('mt-5 flex flex-col gap-4')}>
{options.map((option, index) => (
<div key={option.id}>
<AutoSizeTextArea
ref={el => 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
}
>
<FunEmojiPicker
open={emojiPickerOpenForOption === option.id}
onOpenChange={open => {
setEmojiPickerOpenForOption(open ? option.id : null);
}}
onSelectEmoji={emojiSelection =>
handleSelectEmoji(option.id, emojiSelection)
}
closeOnSelect
>
<FunEmojiPickerButton i18n={i18n} />
</FunEmojiPicker>
</AutoSizeTextArea>
</div>
))}
</div>
</div>
<div className={tw('mt-8 h-[0.5px] bg-border-primary')} />
<label className={tw('mt-6 flex items-center gap-3')}>
<span className={tw('grow type-body-large')}>
{i18n('icu:PollCreateModal__allowMultipleVotes')}
</span>
<AxoSwitch.Root
checked={allowMultiple}
onCheckedChange={setAllowMultiple}
/>
</label>
<div
className={tw('mt-3 flex min-h-[26px] items-center justify-center')}
>
{validationErrorMessages && (
<div aria-hidden="true">
<Toast onClose={() => setValidationErrorMessages(null)}>
{validationErrorMessages[0]}
</Toast>
</div>
)}
</div>
<div className={tw('mt-3 flex justify-end gap-3')}>
<AxoButton.Root variant="secondary" size="large" onClick={onClose}>
{i18n('icu:cancel')}
</AxoButton.Root>
<AxoButton.Root
variant="primary"
size="large"
onClick={handleSend}
disabled={!allowSend}
>
{i18n('icu:PollCreateModal__sendButton')}
</AxoButton.Root>
</div>
</div>
</Modal>
);
}

View File

@@ -30,6 +30,8 @@ import type { ShowToastActionType } from './toast.preload.js';
import type { StateType as RootStateType } from '../reducer.preload.js'; import type { StateType as RootStateType } from '../reducer.preload.js';
import { createLogger } from '../../logging/log.std.js'; import { createLogger } from '../../logging/log.std.js';
import * as Errors from '../../types/errors.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 { import {
ADD_PREVIEW as ADD_LINK_PREVIEW, ADD_PREVIEW as ADD_LINK_PREVIEW,
REMOVE_PREVIEW as REMOVE_LINK_PREVIEW, REMOVE_PREVIEW as REMOVE_LINK_PREVIEW,
@@ -254,6 +256,7 @@ export const actions = {
scrollToQuotedMessage, scrollToQuotedMessage,
sendEditedMessage, sendEditedMessage,
sendMultiMediaMessage, sendMultiMediaMessage,
sendPoll,
sendStickerMessage, sendStickerMessage,
setComposerFocus, setComposerFocus,
setMediaQualitySetting, 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 // Not cool that we have to pull from ConversationModel here
// but if the current selected conversation isn't the one that we're operating // 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 // on then we won't be able to grab attachments from state so we resort to the

View File

@@ -194,6 +194,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
sendStickerMessage, sendStickerMessage,
sendEditedMessage, sendEditedMessage,
sendMultiMediaMessage, sendMultiMediaMessage,
sendPoll,
setComposerFocus, setComposerFocus,
} = useComposerActions(); } = useComposerActions();
const { const {
@@ -341,6 +342,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
sendStickerMessage={sendStickerMessage} sendStickerMessage={sendStickerMessage}
sendEditedMessage={sendEditedMessage} sendEditedMessage={sendEditedMessage}
sendMultiMediaMessage={sendMultiMediaMessage} sendMultiMediaMessage={sendMultiMediaMessage}
sendPoll={sendPoll}
scrollToMessage={scrollToMessage} scrollToMessage={scrollToMessage}
setComposerFocus={setComposerFocus} setComposerFocus={setComposerFocus}
setMessageToEdit={setMessageToEdit} setMessageToEdit={setMessageToEdit}

View File

@@ -13,6 +13,10 @@ import { isAlpha, isBeta, isProduction } from '../util/version.std.js';
import type { SendStateByConversationId } from '../messages/MessageSendState.std.js'; import type { SendStateByConversationId } from '../messages/MessageSendState.std.js';
import { aciSchema } from './ServiceId.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) // PollCreate schema (processed shape)
// - question: required, 1..100 chars // - question: required, 1..100 chars
// - options: required, 2..10 items; each 1..100 chars // - options: required, 2..10 items; each 1..100 chars
@@ -22,20 +26,23 @@ export const PollCreateSchema = z
question: z question: z
.string() .string()
.min(1) .min(1)
.refine(value => hasAtMostGraphemes(value, 100), { .refine(value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH), {
message: 'question must contain at most 100 characters', message: `question must contain at most ${POLL_QUESTION_MAX_LENGTH} characters`,
}), }),
options: z options: z
.array( .array(
z z
.string() .string()
.min(1) .min(1)
.refine(value => hasAtMostGraphemes(value, 100), { .refine(
message: 'option must contain at most 100 characters', 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(), .readonly(),
allowMultiple: z.boolean().optional(), allowMultiple: z.boolean().optional(),
}) })

View File

@@ -2327,5 +2327,29 @@
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z" "updated": "2021-09-17T21:02:59.414Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.dom.tsx",
"line": " const photoVideoInputRef = useRef<null | HTMLInputElement>(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<HTMLTextAreaElement | null>(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<Map<string, HTMLTextAreaElement | null>>(",
"reasonCategory": "usageTrusted",
"updated": "2025-11-02T17:27:24.705Z",
"reasonDetail": "Map of refs for poll option inputs to manage focus"
} }
] ]