Skip to content

Commit 612aa2b

Browse files
authored
Poll create modal
1 parent 4436184 commit 612aa2b

File tree

12 files changed

+690
-21
lines changed

12 files changed

+690
-21
lines changed

_locales/en/messages.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,42 @@
14981498
"messageformat": "No votes",
14991499
"description": "Message shown when poll has no votes"
15001500
},
1501+
"icu:PollCreateModal__title": {
1502+
"messageformat": "New poll",
1503+
"description": "Title for the modal to create a new poll"
1504+
},
1505+
"icu:PollCreateModal__questionLabel": {
1506+
"messageformat": "Question",
1507+
"description": "Label for the poll question input field"
1508+
},
1509+
"icu:PollCreateModal__questionPlaceholder": {
1510+
"messageformat": "What should we order for lunch?",
1511+
"description": "Placeholder text for the poll question input field"
1512+
},
1513+
"icu:PollCreateModal__optionsLabel": {
1514+
"messageformat": "Options",
1515+
"description": "Label for the poll options section"
1516+
},
1517+
"icu:PollCreateModal__optionPlaceholder": {
1518+
"messageformat": "Option {number}",
1519+
"description": "Placeholder text for poll option input field"
1520+
},
1521+
"icu:PollCreateModal__allowMultipleVotes": {
1522+
"messageformat": "Allow multiple votes",
1523+
"description": "Label for the toggle to allow multiple votes in a poll"
1524+
},
1525+
"icu:PollCreateModal__sendButton": {
1526+
"messageformat": "Send",
1527+
"description": "Send button text in poll creation modal"
1528+
},
1529+
"icu:PollCreateModal__Error--RequiresQuestion": {
1530+
"messageformat": "Poll question is required",
1531+
"description": "Error message shown when poll question is empty"
1532+
},
1533+
"icu:PollCreateModal__Error--RequiresTwoOptions": {
1534+
"messageformat": "Poll requires at least 2 options",
1535+
"description": "Error message shown when poll has fewer than 2 non-empty options"
1536+
},
15011537
"icu:deleteConversation": {
15021538
"messageformat": "Delete",
15031539
"description": "Menu item for deleting a conversation (including messages), title case."
@@ -5828,6 +5864,22 @@
58285864
"messageformat": "Attach file",
58295865
"description": "Aria label for file attachment button in composition area"
58305866
},
5867+
"icu:CompositionArea__AttachMenu__PhotosAndVideos": {
5868+
"messageformat": "Photos & videos",
5869+
"description": "Menu item to attach photos and videos"
5870+
},
5871+
"icu:CompositionArea__AttachMenu__File": {
5872+
"messageformat": "File",
5873+
"description": "Menu item to attach a file"
5874+
},
5875+
"icu:CompositionArea__AttachMenu__Poll": {
5876+
"messageformat": "Poll",
5877+
"description": "Menu item to create a poll"
5878+
},
5879+
"icu:CompositionArea--attach-plus": {
5880+
"messageformat": "Add attachment or poll",
5881+
"description": "Aria label for plus button that opens attachment menu"
5882+
},
58315883
"icu:CompositionArea--sms-only__title": {
58325884
"messageformat": "This person isn’t using Signal",
58335885
"description": "Title for the composition area for the SMS-only contact"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
.PollCreateModalInput {
5+
&__container {
6+
margin-block: 0;
7+
}
8+
}

stylesheets/manifest.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
@use 'components/PermissionsPopup.scss';
151151
@use 'components/PlaybackButton.scss';
152152
@use 'components/PlaybackRateButton.scss';
153+
@use 'components/PollCreateModal.scss';
153154
@use 'components/Preferences.scss';
154155
@use 'components/PreferencesDonations.scss';
155156
@use 'components/ProfileEditor.scss';

ts/components/CompositionArea.dom.tsx

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ import { strictAssert } from '../util/assert.std.js';
7676
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
7777
import type { EmojiSkinTone } from './fun/data/emojis.std.js';
7878
import { FunPickerButton } from './fun/FunButton.dom.js';
79+
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.js';
80+
import { AxoSymbol } from '../axo/AxoSymbol.dom.js';
81+
import { AxoButton } from '../axo/AxoButton.dom.js';
82+
import { tw } from '../axo/tw.dom.js';
83+
import { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js';
84+
import { PollCreateModal } from './PollCreateModal.dom.js';
7985

8086
export type OwnProps = Readonly<{
8187
acceptedMessageRequest: boolean | null;
@@ -160,6 +166,7 @@ export type OwnProps = Readonly<{
160166
voiceNoteAttachment?: InMemoryAttachmentDraftType;
161167
}
162168
): unknown;
169+
sendPoll(conversationId: string, poll: PollCreateType): unknown;
163170
quotedMessageId: string | null;
164171
quotedMessageProps: null | ReadonlyDeep<
165172
Omit<
@@ -244,6 +251,7 @@ export const CompositionArea = memo(function CompositionArea({
244251
removeAttachment,
245252
sendEditedMessage,
246253
sendMultiMediaMessage,
254+
sendPoll,
247255
setComposerFocus,
248256
setMessageToEdit,
249257
setQuoteByMessageId,
@@ -332,8 +340,10 @@ export const CompositionArea = memo(function CompositionArea({
332340
const [attachmentToEdit, setAttachmentToEdit] = useState<
333341
AttachmentDraftType | undefined
334342
>();
343+
const [isPollModalOpen, setIsPollModalOpen] = useState(false);
335344
const inputApiRef = useRef<InputApi | undefined>();
336345
const fileInputRef = useRef<null | HTMLInputElement>(null);
346+
const photoVideoInputRef = useRef<null | HTMLInputElement>(null);
337347

338348
const handleForceSend = useCallback(() => {
339349
setLarge(false);
@@ -407,8 +417,9 @@ export const CompositionArea = memo(function CompositionArea({
407417
]
408418
);
409419

410-
const launchAttachmentPicker = useCallback(() => {
411-
const fileInput = fileInputRef.current;
420+
const launchAttachmentPicker = useCallback((type?: 'media' | 'file') => {
421+
const inputRef = type === 'media' ? photoVideoInputRef : fileInputRef;
422+
const fileInput = inputRef.current;
412423
if (fileInput) {
413424
// Setting the value to empty so that onChange always fires in case
414425
// you add multiple photos.
@@ -417,6 +428,32 @@ export const CompositionArea = memo(function CompositionArea({
417428
}
418429
}, []);
419430

431+
const launchMediaPicker = useCallback(
432+
() => launchAttachmentPicker('media'),
433+
[launchAttachmentPicker]
434+
);
435+
436+
const launchFilePicker = useCallback(
437+
() => launchAttachmentPicker('file'),
438+
[launchAttachmentPicker]
439+
);
440+
441+
const handleOpenPollModal = useCallback(() => {
442+
setIsPollModalOpen(true);
443+
}, []);
444+
445+
const handleClosePollModal = useCallback(() => {
446+
setIsPollModalOpen(false);
447+
}, []);
448+
449+
const handleSendPoll = useCallback(
450+
(poll: PollCreateType) => {
451+
sendPoll(conversationId, poll);
452+
handleClosePollModal();
453+
},
454+
[conversationId, sendPoll, handleClosePollModal]
455+
);
456+
420457
function maybeEditAttachment(attachment: AttachmentDraftType) {
421458
if (!isImageTypeSupported(attachment.contentType)) {
422459
return;
@@ -444,7 +481,7 @@ export const CompositionArea = memo(function CompositionArea({
444481

445482
const [hasFocus, setHasFocus] = useState(false);
446483

447-
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker);
484+
const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
448485
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
449486
useKeyboardShortcutsConditionally(
450487
hasFocus,
@@ -708,17 +745,56 @@ export const CompositionArea = memo(function CompositionArea({
708745
) : null;
709746

710747
const isRecording = recordingState === RecordingState.Recording;
711-
const attButton =
712-
draftEditMessage || linkPreviewResult || isRecording ? undefined : (
748+
749+
let attButton;
750+
if (draftEditMessage || linkPreviewResult || isRecording) {
751+
attButton = undefined;
752+
} else if (isPollSendEnabled()) {
753+
attButton = (
754+
<div className="CompositionArea__button-cell">
755+
<AxoDropdownMenu.Root>
756+
<AxoDropdownMenu.Trigger>
757+
<div className={tw('flex h-8 items-center')}>
758+
<AxoButton.Root
759+
variant="borderless-secondary"
760+
size="small"
761+
aria-label={i18n('icu:CompositionArea--attach-plus')}
762+
>
763+
<AxoSymbol.Icon label={null} symbol="plus" size={20} />
764+
</AxoButton.Root>
765+
</div>
766+
</AxoDropdownMenu.Trigger>
767+
<AxoDropdownMenu.Content>
768+
<AxoDropdownMenu.Item symbol="photo" onSelect={launchMediaPicker}>
769+
{i18n('icu:CompositionArea__AttachMenu__PhotosAndVideos')}
770+
</AxoDropdownMenu.Item>
771+
<AxoDropdownMenu.Item symbol="file" onSelect={launchFilePicker}>
772+
{i18n('icu:CompositionArea__AttachMenu__File')}
773+
</AxoDropdownMenu.Item>
774+
{conversationType === 'group' && (
775+
<AxoDropdownMenu.Item
776+
symbol="poll"
777+
onSelect={handleOpenPollModal}
778+
>
779+
{i18n('icu:CompositionArea__AttachMenu__Poll')}
780+
</AxoDropdownMenu.Item>
781+
)}
782+
</AxoDropdownMenu.Content>
783+
</AxoDropdownMenu.Root>
784+
</div>
785+
);
786+
} else {
787+
attButton = (
713788
<div className="CompositionArea__button-cell">
714789
<button
715790
type="button"
716791
className="CompositionArea__attach-file"
717-
onClick={launchAttachmentPicker}
792+
onClick={launchFilePicker}
718793
aria-label={i18n('icu:CompositionArea--attach-file')}
719794
/>
720795
</div>
721796
);
797+
}
722798

723799
const sendButtonFragment = !draftEditMessage ? (
724800
<>
@@ -1049,7 +1125,7 @@ export const CompositionArea = memo(function CompositionArea({
10491125
attachments={draftAttachments}
10501126
canEditImages
10511127
i18n={i18n}
1052-
onAddAttachment={launchAttachmentPicker}
1128+
onAddAttachment={launchFilePicker}
10531129
onClickAttachment={maybeEditAttachment}
10541130
onClose={() => onClearAttachments(conversationId)}
10551131
onCloseAttachment={attachment => {
@@ -1133,6 +1209,22 @@ export const CompositionArea = memo(function CompositionArea({
11331209
processAttachments={processAttachments}
11341210
ref={fileInputRef}
11351211
/>
1212+
<CompositionUpload
1213+
conversationId={conversationId}
1214+
draftAttachments={draftAttachments}
1215+
i18n={i18n}
1216+
processAttachments={processAttachments}
1217+
ref={photoVideoInputRef}
1218+
acceptMediaOnly
1219+
testId="attachfile-input-media"
1220+
/>
1221+
{isPollModalOpen && (
1222+
<PollCreateModal
1223+
i18n={i18n}
1224+
onClose={handleClosePollModal}
1225+
onSendPoll={handleSendPoll}
1226+
/>
1227+
)}
11361228
</div>
11371229
);
11381230
});

ts/components/CompositionUpload.dom.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@ export type PropsType = {
2525
files: ReadonlyArray<File>;
2626
flags: number | null;
2727
}) => unknown;
28+
acceptMediaOnly?: boolean;
29+
testId?: string;
2830
};
2931

3032
export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
3133
function CompositionUploadInner(
32-
{ conversationId, draftAttachments, processAttachments },
34+
{
35+
conversationId,
36+
draftAttachments,
37+
processAttachments,
38+
acceptMediaOnly,
39+
testId,
40+
},
3341
ref
3442
) {
3543
const onFileInputChange: ChangeEventHandler<
@@ -48,13 +56,14 @@ export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
4856
return isImageAttachment(attachment) || isVideoAttachment(attachment);
4957
});
5058

51-
const acceptContentTypes = anyVideoOrImageAttachments
52-
? [...getSupportedImageTypes(), ...getSupportedVideoTypes()]
53-
: null;
59+
const acceptContentTypes =
60+
acceptMediaOnly || anyVideoOrImageAttachments
61+
? [...getSupportedImageTypes(), ...getSupportedVideoTypes()]
62+
: null;
5463

5564
return (
5665
<input
57-
data-testid="attachfile-input"
66+
data-testid={testId ?? 'attachfile-input'}
5867
hidden
5968
multiple
6069
onChange={onFileInputChange}

ts/components/Input.dom.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ export type PropsType = {
3535
onChange: (value: string) => unknown;
3636
onBlur?: () => unknown;
3737
onFocus?: () => unknown;
38-
onEnter?: () => unknown;
38+
onEnter?: (event: KeyboardEvent) => unknown;
3939
placeholder: string;
4040
readOnly?: boolean;
4141
value?: string;
4242
whenToShowRemainingCount?: number;
4343
whenToWarnRemainingCount?: number;
4444
children?: ReactNode;
45+
'aria-invalid'?: boolean | 'true' | 'false';
46+
'aria-errormessage'?: string;
4547
};
4648

4749
/**
@@ -90,6 +92,8 @@ export const Input = forwardRef<
9092
whenToShowRemainingCount = Infinity,
9193
whenToWarnRemainingCount = Infinity,
9294
children,
95+
'aria-invalid': ariaInvalid,
96+
'aria-errormessage': ariaErrorMessage,
9397
},
9498
ref
9599
) {
@@ -120,7 +124,7 @@ export const Input = forwardRef<
120124
const handleKeyDown = useCallback(
121125
(event: KeyboardEvent) => {
122126
if (onEnter && event.key === 'Enter') {
123-
onEnter();
127+
onEnter(event);
124128
}
125129

126130
const inputEl = innerRef.current;
@@ -235,6 +239,8 @@ export const Input = forwardRef<
235239
),
236240
type: 'text',
237241
value,
242+
'aria-invalid': ariaInvalid,
243+
'aria-errormessage': ariaErrorMessage,
238244
};
239245

240246
const clearButtonElement =
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
import React from 'react';
5+
import { action } from '@storybook/addon-actions';
6+
import type { Meta } from '@storybook/react';
7+
import type { PollCreateModalProps } from './PollCreateModal.dom.js';
8+
import { PollCreateModal } from './PollCreateModal.dom.js';
9+
10+
const { i18n } = window.SignalContext;
11+
12+
export default {
13+
title: 'Components/PollCreateModal',
14+
} satisfies Meta<PollCreateModalProps>;
15+
16+
const onClose = action('onClose');
17+
const onSendPoll = action('onSendPoll');
18+
19+
export function Default(): JSX.Element {
20+
return (
21+
<PollCreateModal i18n={i18n} onClose={onClose} onSendPoll={onSendPoll} />
22+
);
23+
}

0 commit comments

Comments
 (0)