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",
|
"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 isn’t using Signal",
|
"messageformat": "This person isn’t using Signal",
|
||||||
"description": "Title for the composition area for the SMS-only contact"
|
"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/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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user