mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Poll create modal
This commit is contained in:
@@ -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"
|
||||
|
||||
8
stylesheets/components/PollCreateModal.scss
Normal file
8
stylesheets/components/PollCreateModal.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.PollCreateModalInput {
|
||||
&__container {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<InputApi | undefined>();
|
||||
const fileInputRef = useRef<null | HTMLInputElement>(null);
|
||||
const photoVideoInputRef = useRef<null | HTMLInputElement>(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 = (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="CompositionArea__attach-file"
|
||||
onClick={launchAttachmentPicker}
|
||||
onClick={launchFilePicker}
|
||||
aria-label={i18n('icu:CompositionArea--attach-file')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,11 +25,19 @@ export type PropsType = {
|
||||
files: ReadonlyArray<File>;
|
||||
flags: number | null;
|
||||
}) => unknown;
|
||||
acceptMediaOnly?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
|
||||
function CompositionUploadInner(
|
||||
{ conversationId, draftAttachments, processAttachments },
|
||||
{
|
||||
conversationId,
|
||||
draftAttachments,
|
||||
processAttachments,
|
||||
acceptMediaOnly,
|
||||
testId,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const onFileInputChange: ChangeEventHandler<
|
||||
@@ -48,13 +56,14 @@ export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
|
||||
return isImageAttachment(attachment) || isVideoAttachment(attachment);
|
||||
});
|
||||
|
||||
const acceptContentTypes = anyVideoOrImageAttachments
|
||||
? [...getSupportedImageTypes(), ...getSupportedVideoTypes()]
|
||||
: null;
|
||||
const acceptContentTypes =
|
||||
acceptMediaOnly || anyVideoOrImageAttachments
|
||||
? [...getSupportedImageTypes(), ...getSupportedVideoTypes()]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<input
|
||||
data-testid="attachfile-input"
|
||||
data-testid={testId ?? 'attachfile-input'}
|
||||
hidden
|
||||
multiple
|
||||
onChange={onFileInputChange}
|
||||
|
||||
@@ -35,13 +35,15 @@ export type PropsType = {
|
||||
onChange: (value: string) => 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 =
|
||||
|
||||
23
ts/components/PollCreateModal.dom.stories.tsx
Normal file
23
ts/components/PollCreateModal.dom.stories.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
393
ts/components/PollCreateModal.dom.tsx
Normal file
393
ts/components/PollCreateModal.dom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 | 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"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user