From a2d6d63853a875ef2478f23529f59407b4ea3145 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 5 May 2026 13:46:45 +0200 Subject: [PATCH] Polls: Redesign modal (#6886) --- CLAUDE.md | 3 +- package.json | 4 +- src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/gramjs/gramjsBuilders/index.ts | 10 +- src/api/gramjs/methods/messages.ts | 18 + src/api/types/misc.ts | 3 +- src/assets/font-icons/check-filled.svg | 1 + src/assets/font-icons/choice-selected.svg | 1 + src/assets/font-icons/replace-round.svg | 1 + src/assets/font-icons/timer-filled.svg | 1 + src/assets/localization/fallback.strings | 30 +- src/bundles/extra.ts | 2 +- src/components/common/CalendarModal.tsx | 1 + src/components/common/Composer.tsx | 49 +- .../gili/layout/Control.module.scss | 130 +-- src/components/gili/layout/Control.tsx | 26 + .../gili/layout/Interactive.module.scss | 2 + src/components/gili/layout/Island.module.scss | 21 + src/components/gili/layout/Island.tsx | 18 +- src/components/gili/modal/Modal.module.scss | 281 ++++++ src/components/gili/modal/Modal.tsx | 477 +++++++++ .../gili/primitives/Switch.module.scss | 114 ++- src/components/gili/primitives/Switch.tsx | 37 +- src/components/mediaViewer/MediaViewer.tsx | 5 +- src/components/middle/composer/AttachMenu.tsx | 12 +- src/components/middle/composer/PollModal.scss | 107 -- src/components/middle/composer/PollModal.tsx | 385 ------- .../middle/message/poll/Poll.module.scss | 1 + src/components/middle/message/poll/Poll.tsx | 120 ++- .../message/poll/PollOption.module.scss | 38 + .../middle/message/poll/PollOption.tsx | 3 +- src/components/modals/ModalContainer.tsx | 104 +- .../poll}/PollModal.async.tsx | 0 .../modals/poll/PollModal.module.scss | 141 +++ src/components/modals/poll/PollModal.tsx | 949 ++++++++++++++++++ src/components/ui/ConfirmDialog.tsx | 1 + src/components/ui/Modal.scss | 23 + src/components/ui/Modal.tsx | 92 +- src/global/actions/api/bots.ts | 11 +- src/global/actions/api/messages.ts | 13 + src/global/actions/ui/messages.ts | 23 +- src/global/initialState.ts | 4 - src/global/types/actions.ts | 12 +- src/global/types/tabState.ts | 7 +- src/hooks/useReorderableList.ts | 545 ++++++++++ src/lib/gramjs/tl/apiTl.ts | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/lib/teact/teact-dom.ts | 4 +- src/limits.ts | 2 + src/styles/icons.css | 572 +++++------ src/styles/icons.scss | 560 ++++++----- src/styles/icons.woff | Bin 54220 -> 54792 bytes src/styles/icons.woff2 | Bin 46176 -> 46608 bytes src/styles/icons/preview.html | 580 +++++------ src/types/icons/font.ts | 4 + src/types/language.d.ts | 25 +- 56 files changed, 4002 insertions(+), 1575 deletions(-) create mode 100644 src/assets/font-icons/check-filled.svg create mode 100644 src/assets/font-icons/choice-selected.svg create mode 100644 src/assets/font-icons/replace-round.svg create mode 100644 src/assets/font-icons/timer-filled.svg create mode 100644 src/components/gili/modal/Modal.module.scss create mode 100644 src/components/gili/modal/Modal.tsx delete mode 100644 src/components/middle/composer/PollModal.scss delete mode 100644 src/components/middle/composer/PollModal.tsx rename src/components/{middle/composer => modals/poll}/PollModal.async.tsx (100%) create mode 100644 src/components/modals/poll/PollModal.module.scss create mode 100644 src/components/modals/poll/PollModal.tsx create mode 100644 src/hooks/useReorderableList.ts diff --git a/CLAUDE.md b/CLAUDE.md index 610833a66..2fa91b8d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe - **Always extract styles to files** - avoid inline styles unless absolutely necessary. - **If file already imports styles**, check where they come from and add new styles there - don't create new style files. - Prefer rem units for all measurements. Exceptions are possible, but usually rare. + - No complex or broad selectors. Prefer basic classes. - **Code Style:** - Early returns. @@ -176,7 +177,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => { * **StateProps**: data injected by `withGlobal` HOC * Merge them as `OwnProps & StateProps` when defining your component. * You can skip one or both if they are not used. -* **Order rule**: list any function types *last* in your props definitions. +* **Order rule**: list any handlers or functions *last* in your props definitions. * Do not pass unmemoized objects as props into memo() components. ### 3. Hooks diff --git a/package.json b/package.json index 241ceb6c3..9af6d1ae4 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "npm": "^10.8 || ^11" }, "lint-staged": { - "*.{ts,tsx,js}": "eslint --fix", - "*.{css,scss}": "stylelint --fix" + "*.{ts,tsx,js}": "eslint --cache --cache-location .cache/.eslintcache --fix", + "*.{css,scss}": "stylelint --cache --cache-location .cache/.stylelintcache --fix" }, "devDependencies": { "@babel/core": "^7.29.0", diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index b0425051c..f24ae9b62 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -118,6 +118,7 @@ export interface GramJsAppConfig extends LimitsConfig { ton_usd_rate?: number; ton_topup_url?: string; poll_answers_max?: number; + poll_close_period_max?: number; todo_items_max?: number; todo_title_length_max?: number; todo_item_length_max?: number; @@ -266,6 +267,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp tonUsdRate: appConfig.ton_usd_rate, tonTopupUrl: appConfig.ton_topup_url, pollMaxAnswers: appConfig.poll_answers_max, + pollClosePeriodMax: appConfig.poll_close_period_max, todoItemsMax: appConfig.todo_items_max, todoTitleLengthMax: appConfig.todo_title_length_max, todoItemLengthMax: appConfig.todo_item_length_max, diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 0963bd321..cfcf64d06 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -235,18 +235,24 @@ export function buildInputPoll( }); }), quiz: poll.isQuiz, + closeDate: poll.closeDate, + closePeriod: poll.closePeriod, + hideResultsUntilClose: poll.shouldHideResultsUntilClose, + revotingDisabled: poll.isRevoteDisabled, + shuffleAnswers: poll.shouldShuffleAnswers, + openAnswers: poll.canAddAnswers, multipleChoice: poll.isMultipleChoice, hash: DEFAULT_PRIMITIVES.BIGINT, }); - const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity); + const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity) || []; return new GramJs.InputMediaPoll({ poll: inputPoll, correctAnswers, attachedMedia: media?.attachedMedia, solution, - solutionEntities: inputSolutionEntities, + solutionEntities: solution ? inputSolutionEntities : undefined, solutionMedia: media?.solutionMedia, }); } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 318cca519..ac6c54332 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1877,6 +1877,24 @@ export async function sendPollVote({ })); } +export async function appendPollAnswer({ + chat, messageId, text, +}: { + chat: ApiChat; + messageId: number; + text: string; +}) { + const { id, accessHash } = chat; + + await invokeRequest(new GramJs.messages.AddPollAnswer({ + peer: buildInputPeer(id, accessHash), + msgId: messageId, + answer: new GramJs.InputPollAnswer({ + text: buildInputTextWithEntities({ text }), + }), + })); +} + export async function toggleTodoCompleted({ chat, messageId, completedIds, incompletedIds, }: { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 2dae0b03f..24d5bcd0a 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -320,7 +320,8 @@ export interface ApiAppConfig { tonStargiftResaleCommissionPermille?: number; tonUsdRate?: number; tonTopupUrl: string; - pollMaxAnswers?: number; + pollMaxAnswers: number; + pollClosePeriodMax: number; todoItemsMax: number; todoTitleLengthMax: number; todoItemLengthMax: number; diff --git a/src/assets/font-icons/check-filled.svg b/src/assets/font-icons/check-filled.svg new file mode 100644 index 000000000..41221f243 --- /dev/null +++ b/src/assets/font-icons/check-filled.svg @@ -0,0 +1 @@ + diff --git a/src/assets/font-icons/choice-selected.svg b/src/assets/font-icons/choice-selected.svg new file mode 100644 index 000000000..2457f5296 --- /dev/null +++ b/src/assets/font-icons/choice-selected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/replace-round.svg b/src/assets/font-icons/replace-round.svg new file mode 100644 index 000000000..7b66ade40 --- /dev/null +++ b/src/assets/font-icons/replace-round.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/timer-filled.svg b/src/assets/font-icons/timer-filled.svg new file mode 100644 index 000000000..82e30da47 --- /dev/null +++ b/src/assets/font-icons/timer-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 1ca67d2e9..90cb3cd23 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -685,17 +685,30 @@ "Message" = "Message"; "RecentStickers" = "Recently Used"; "PollsChooseQuestion" = "Please enter a question."; -"PollsChooseAnswers" = "Please enter at least two options."; +"PollsChooseAnswers" = "Please enter at least one option."; "NewPoll" = "New Poll"; "Create" = "Create"; "OptionHint" = "Option"; "CreatePollAddOption" = "Add an Option"; "PollsChooseCorrect" = "Please choose the correct answer."; "AskAQuestion" = "Ask a Question"; -"PollOptions" = "Poll options"; -"PollAnonymous" = "Anonymous Poll"; -"PollMultiple" = "Multiple Answers"; -"PollQuiz" = "Quiz Mode"; +"PollAnswersVisible" = "Show Who Voted"; +"PollAnswersVisibleDescription" = "Display voter name on each option"; +"PollMultiple" = "Allow Multiple Answers"; +"PollMultipleDescription" = "Voters can select more than one option"; +"PollQuiz" = "Set Correct Answer"; +"PollQuizDescription" = "Mark one or more options as the right answer"; +"PollAllowAddingAnswers" = "Allow Adding Options"; +"PollAllowAddingAnswersDescription" = "Participants can suggest new options"; +"PollAllowVoteChanges" = "Allow Revoting"; +"PollAllowVoteChangesDescription" = "Voters can change their vote"; +"PollRandomOrder" = "Shuffle Options"; +"PollRandomOrderDescription" = "Answers appear in random order for each voter"; +"PollLimitedDuration" = "Limited Duration"; +"PollLimitedDurationDescription" = "Automatically close the poll at a set time"; +"PollDuration" = "Duration"; +"PollHideResultsUntilClose" = "Hide Results Until Close"; +"PollSelectCloseDate" = "Select Date"; "PollsSolutionTitle" = "Explanation"; "CreatePollExplanationInfo" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; "VoipGroupPersonalAccount" = "personal account"; @@ -2888,3 +2901,10 @@ "AiMessageEditorTo" = "To"; "ButtonHelp" = "Help"; "UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure."; +"PollModalQuestionTitle" = "Question"; +"PollModalOptionsTitle" = "Options"; +"PollModalSettingsTitle" = "Settings"; +"PollModalAddMoreText_one" = "You can add {count} more option."; +"PollModalAddMoreText_other" = "You can add {count} more options."; +"PollModalAddNoMore" = "You have added the maximum number of options."; +"PollDurationOther" = "Other"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 948cac0f6..0614c080a 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -75,7 +75,7 @@ export { default as AiMessageEditorModal } from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; -export { default as PollModal } from '../components/middle/composer/PollModal'; +export { default as PollModal } from '../components/modals/poll/PollModal'; export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal'; export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; export { default as ChatCommandTooltip } from '../components/middle/composer/ChatCommandTooltip'; diff --git a/src/components/common/CalendarModal.tsx b/src/components/common/CalendarModal.tsx index 1b757c8ad..59af3bc02 100644 --- a/src/components/common/CalendarModal.tsx +++ b/src/components/common/CalendarModal.tsx @@ -382,6 +382,7 @@ const CalendarModal = ({ onEnter={handleSubmit} dialogRef={dialogRef} hasAbsoluteCloseButton + isNativeDialog >
diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index ece0022f0..288abce4e 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -19,7 +19,6 @@ import type { ApiMessage, ApiMessageEntity, ApiNewMediaTodo, - ApiNewPoll, ApiPeer, ApiQuickReply, ApiReaction, @@ -189,7 +188,6 @@ import EmojiTooltip from '../middle/composer/EmojiTooltip.async'; import InlineBotTooltip from '../middle/composer/InlineBotTooltip.async'; import MentionTooltip from '../middle/composer/MentionTooltip.async'; import MessageInput from '../middle/composer/MessageInput'; -import PollModal from '../middle/composer/PollModal.async'; import SendAsMenu from '../middle/composer/SendAsMenu.async'; import StickerTooltip from '../middle/composer/StickerTooltip.async'; import SymbolMenuButton from '../middle/composer/SymbolMenuButton'; @@ -253,7 +251,6 @@ type StateProps = { isReplying?: boolean; hasSuggestedPost?: boolean; forwardedMessagesCount?: number; - pollModal: TabState['pollModal']; todoListModal: TabState['todoListModal']; aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult']; botKeyboardMessageId?: number; @@ -321,7 +318,6 @@ type StateProps = { isAccountFrozen?: boolean; isAppConfigLoaded?: boolean; insertingPeerIdMention?: string; - pollMaxAnswers?: number; replyToMessage?: ApiMessage; shouldOpenMessageMediaEditor?: TabState['shouldOpenMessageMediaEditor']; }; @@ -382,7 +378,6 @@ const Composer = ({ isReplying, hasSuggestedPost, forwardedMessagesCount, - pollModal, todoListModal, aiMessageEditorPendingResult, botKeyboardMessageId, @@ -449,7 +444,6 @@ const Composer = ({ isAccountFrozen, isAppConfigLoaded, insertingPeerIdMention, - pollMaxAnswers, replyToMessage, shouldOpenMessageMediaEditor, onDropHide, @@ -462,8 +456,6 @@ const Composer = ({ clearDraft, saveDraft, showDialog, - openPollModal, - closePollModal, openTodoListModal, closeTodoListModal, openAiMessageEditorModal, @@ -1654,31 +1646,6 @@ const Composer = ({ }); }); - const handlePollSend = useLastCallback((poll: ApiNewPoll) => { - if (!currentMessageList) { - return; - } - - if (isInScheduledList) { - requestCalendar((scheduledAt, scheduleRepeatPeriod) => { - handleActionWithPaymentConfirmation( - handleMessageSchedule, - { poll }, - scheduledAt, - scheduleRepeatPeriod, - currentMessageList, - ); - }); - closePollModal(); - } else { - handleActionWithPaymentConfirmation( - sendMessage, - { messageList: currentMessageList, poll, isSilent: isSilentPosting }, - ); - closePollModal(); - } - }); - const handleToDoListSend = useLastCallback((todo: ApiNewMediaTodo) => { if (!currentMessageList) { return; @@ -2180,14 +2147,6 @@ const Composer = ({ canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled} paidMessagesStars={paidMessagesStars} /> - ( (global, { chatId, threadId, storyId, messageListType, isMobile, type, }): Complete => { - const appConfig = global.appConfig; const chat = selectChat(global, chatId); const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined; const isChatWithBot = Boolean(chatBot); @@ -2824,7 +2781,6 @@ export default memo(withGlobal( isReplying, hasSuggestedPost, forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined, - pollModal: tabState.pollModal, todoListModal: tabState.todoListModal, aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult, stickersForEmoji: global.stickers.forEmoji.stickers, @@ -2883,7 +2839,9 @@ export default memo(withGlobal( shouldPaidMessageAutoApprove, isSilentPosting, isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen - && !tabState.aiMessageEditorModal, + && !tabState.aiMessageEditorModal + && !tabState.pollModal + && !tabState.sharePreparedMessageModal, starsBalance, isStarsBalanceModalOpen, shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton, @@ -2891,7 +2849,6 @@ export default memo(withGlobal( isAccountFrozen, isAppConfigLoaded, insertingPeerIdMention, - pollMaxAnswers: appConfig.pollMaxAnswers, shouldOpenMessageMediaEditor, replyToMessage, }; diff --git a/src/components/gili/layout/Control.module.scss b/src/components/gili/layout/Control.module.scss index 9a7eeee3a..ccde4e696 100644 --- a/src/components/gili/layout/Control.module.scss +++ b/src/components/gili/layout/Control.module.scss @@ -1,89 +1,27 @@ @layer ui.layout { .control { + --control-grid-template-areas: "input before label after"; + --control-grid-columns: auto auto 1fr auto; + --control-gap: 1rem; + display: grid; - grid-template-areas: "input label"; - grid-template-columns: auto 1fr; + grid-template-areas: var(--control-grid-template-areas); + grid-template-columns: var(--control-grid-columns); flex-grow: 1; - column-gap: 1rem; + column-gap: 0; align-items: center; - &:has(> .controlDescription) { - grid-template-areas: "input label" "input desc"; - } - - &:has(> .controlAfter) { - grid-template-areas: "input label after"; - grid-template-columns: auto 1fr auto; - - &:has(> .controlDescription) { - grid-template-areas: "input label after" "input desc after"; - } - } - - &:has(> .controlBefore) { - grid-template-areas: "input before label"; - grid-template-columns: auto auto 1fr; - - &:has(> .controlDescription) { - grid-template-areas: "input before label" "input before desc"; - } - } - - &:has(> .controlBefore):has(> .controlAfter) { - grid-template-areas: "input before label after"; - grid-template-columns: auto auto 1fr auto; - - &:has(> .controlDescription) { - grid-template-areas: "input before label after" "input before desc after"; - } - } - - // --- inputEnd: input at end --- &.inputEnd { - grid-template-areas: "label input"; - grid-template-columns: 1fr auto; - - &:has(> .controlDescription) { - grid-template-areas: "label input" "desc input"; - } - - &:has(> .controlAfter) { - grid-template-areas: "label after input"; - grid-template-columns: 1fr auto auto; - - &:has(> .controlDescription) { - grid-template-areas: "label after input" "desc after input"; - } - } - - &:has(> .controlBefore) { - grid-template-areas: "before label input"; - grid-template-columns: auto 1fr auto; - - &:has(> .controlDescription) { - grid-template-areas: "before label input" "before desc input"; - } - } - - &:has(> .controlBefore):has(> .controlAfter) { - grid-template-areas: "before label after input"; - grid-template-columns: auto 1fr auto auto; - - &:has(> .controlDescription) { - grid-template-areas: "before label after input" "before desc after input"; - } - } + --control-grid-template-areas: "before label after input"; + --control-grid-columns: auto 1fr auto auto; } - &:has(> .controlDescription) > .controlLabel { - align-self: end; - } + &:has(> .controlDescription) { + --control-grid-template-areas: "input before label after" "input before desc after"; - &:has(> .controlDescription) > .input, - &:has(> .controlDescription) > .spinner { - transform: translateY(50%); - grid-row: 1; - align-self: end; + &.inputEnd { + --control-grid-template-areas: "before label after input" "before desc after input"; + } } :global(label) { @@ -94,6 +32,12 @@ .input { grid-area: input; align-self: center; + margin-inline-end: var(--control-gap); + + .inputEnd > & { + margin-inline-start: var(--control-gap); + margin-inline-end: 0; + } } .spinner { @@ -101,6 +45,12 @@ grid-area: input; align-self: center; + margin-inline-end: var(--control-gap); + + .inputEnd > & { + margin-inline-start: var(--control-gap); + margin-inline-end: 0; + } } .controlLabel { @@ -130,10 +80,38 @@ .controlBefore { grid-area: before; align-self: center; + margin-inline-end: var(--control-gap); } .controlAfter { grid-area: after; align-self: center; + margin-inline-start: var(--control-gap); + } + + .controlIcon { + --control-icon-background-color: transparent; + + display: flex; + align-items: center; + justify-content: center; + + width: 2rem; + height: 2rem; + border-radius: 0.625rem; + + color: var(--color-text); + + background-color: var(--control-icon-background-color); + background-image: none; + } + + .controlIconFilled { + color: white; + background-image: linear-gradient(180deg, rgb(255 255 255 / 30%) 0%, transparent 100%); + } + + .controlIconGlyph { + font-size: 1.25rem; } } diff --git a/src/components/gili/layout/Control.tsx b/src/components/gili/layout/Control.tsx index 21f7bc4d3..46875b97b 100644 --- a/src/components/gili/layout/Control.tsx +++ b/src/components/gili/layout/Control.tsx @@ -1,12 +1,15 @@ import type { TeactNode } from '../../../lib/teact/teact'; import { createContext, memo, useMemo } from '../../../lib/teact/teact'; +import type { IconName } from '../../../types/icons'; + import buildClassName from '../../../util/buildClassName'; import useContext from '../../../hooks/data/useContext'; import useLang from '../../../hooks/useLang'; import useUniqueId from '../../../hooks/useUniqueId'; +import Icon from '../../common/icons/Icon'; import Spinner from '../../ui/Spinner'; import { useInteractiveContext } from './Interactive'; @@ -123,6 +126,12 @@ type ControlSlotProps = { children: TeactNode; }; +type ControlIconProps = { + iconName?: IconName; + className?: string; + backgroundColor?: string; +}; + const ControlBefore = ({ className, children }: ControlSlotProps) => { return (
@@ -139,6 +148,22 @@ const ControlAfter = ({ className, children }: ControlSlotProps) => { ); }; +const ControlIcon = ({ iconName, className, backgroundColor }: ControlIconProps) => { + return ( + +
+ {iconName && } +
+
+ ); +}; + // #endregion export default memo(Control); @@ -147,4 +172,5 @@ export { ControlDescription, ControlBefore, ControlAfter, + ControlIcon, }; diff --git a/src/components/gili/layout/Interactive.module.scss b/src/components/gili/layout/Interactive.module.scss index 2f45e9c70..ac9fb1dd6 100644 --- a/src/components/gili/layout/Interactive.module.scss +++ b/src/components/gili/layout/Interactive.module.scss @@ -10,6 +10,8 @@ margin: 0; padding: 0.5rem 0.75rem; border-radius: var(--border-radius-default); + + transition: opacity 0.15s ease; } .clickable { diff --git a/src/components/gili/layout/Island.module.scss b/src/components/gili/layout/Island.module.scss index 5966b9ed2..053574c23 100644 --- a/src/components/gili/layout/Island.module.scss +++ b/src/components/gili/layout/Island.module.scss @@ -17,6 +17,18 @@ overflow-wrap: anywhere; } + .title { + display: block; + + padding: 0 1rem; + + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + line-height: 1.25rem; + color: var(--color-text-secondary); + overflow-wrap: anywhere; + } + .text { display: block; padding: 0.5rem 1rem; @@ -35,4 +47,13 @@ .description + .island { margin-top: 1rem; } + + .title + .island { + margin-top: 0.5rem; + } + + .description + .title, + .island + .title { + margin-top: 1rem; + } } diff --git a/src/components/gili/layout/Island.tsx b/src/components/gili/layout/Island.tsx index b7601f319..eb07661ba 100644 --- a/src/components/gili/layout/Island.tsx +++ b/src/components/gili/layout/Island.tsx @@ -1,14 +1,18 @@ +import type { ElementRef } from '../../../lib/teact/teact'; + import buildClassName from '../../../util/buildClassName'; import styles from './Island.module.scss'; type OwnProps = React.HTMLAttributes & { children: React.ReactNode; + ref?: ElementRef; }; -const Island = ({ className, children, ...otherProps }: OwnProps) => { +const Island = ({ ref, className, children, ...otherProps }: OwnProps) => { return (
@@ -28,6 +32,17 @@ const IslandDescription = ({ className, children, ...otherProps }: OwnProps) => ); }; +const IslandTitle = ({ className, children, ...otherProps }: OwnProps) => { + return ( +
+ {children} +
+ ); +}; + const IslandText = ({ className, children, ...otherProps }: OwnProps) => { return (
{ export default Island; export { IslandDescription, + IslandTitle, IslandText, }; diff --git a/src/components/gili/modal/Modal.module.scss b/src/components/gili/modal/Modal.module.scss new file mode 100644 index 000000000..df7a0def0 --- /dev/null +++ b/src/components/gili/modal/Modal.module.scss @@ -0,0 +1,281 @@ +@layer ui.modal { + .dialog { + --modal-max-width: 35rem; + --modal-max-height: min(92dvh, 50rem); + --modal-header-height: 3.5rem; + --modal-content-block-padding: 1rem; + --modal-scroll-fade-size: 1rem; + --modal-border-radius: 1.75rem; + + user-select: none; + + position: fixed; + inset: 0; + + display: grid; + place-items: center; + + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + border: 0; + + color: var(--color-text); + + background: transparent; + outline: none; + + &::backdrop { + background-color: rgb(0 0 0 / 30%); + } + } + + .contained { + contain: content; + } + + .open .panel { + animation: panelOpen 200ms ease-out; + } + + .open:not(.noBackdrop)::backdrop { + animation: backdropOpen 200ms ease-out; + } + + .closing .panel { + animation: panelClose 200ms ease-in forwards; + } + + .closing:not(.noBackdrop)::backdrop { + animation: backdropClose 200ms ease-in forwards; + } + + .noBackdrop::backdrop { + background-color: transparent; + } + + .fullscreen { + --_dialog-padding: 0; + + .panel { + border-radius: 0; + } + } + + .widthSlim { + --modal-max-width: 26.25rem; + } + + .widthRegular { + --modal-max-width: 35rem; + } + + .widthWide { + --modal-max-width: 44rem; + } + + .widthFullscreen { + --modal-max-width: 100dvw; + + .panel { + width: 100%; + max-width: none; + } + } + + .heightAuto { + .panel { + height: auto; + } + + .content { + flex: 0 1 auto; + } + } + + .heightRegular { + --modal-max-height: min(92dvh, 50rem); + } + + .heightTall { + --modal-max-height: min(96dvh, 62rem); + } + + .heightFullscreen { + --modal-max-height: 100dvh; + + .panel { + height: 100%; + max-height: none; + } + } + + .panel { + position: relative; + + overflow: hidden; + display: flex; + flex-direction: column; + + width: min(100%, var(--modal-max-width)); + max-width: var(--modal-max-width); + max-height: min(100%, var(--modal-max-height)); + border-radius: var(--modal-border-radius); + + background-color: var(--color-background-secondary); + box-shadow: 0 0.625rem 2rem rgb(0 0 0 / 18%); + } + + .headerSlot { + position: absolute; + z-index: 1; + top: 0; + right: 0; + left: 0; + } + + .content { + flex: 1 1 auto; + min-height: 0; + border-radius: inherit; + background-color: transparent; + } + + .withHeader { + mask-image: + linear-gradient( + to bottom, + transparent 0, + transparent calc(var(--modal-header-height) - var(--modal-scroll-fade-size)), + black calc(var(--modal-header-height) + var(--modal-scroll-fade-size)), + black 100% + ); + } + + .body { + min-height: 100%; + padding-block: var(--modal-content-block-padding); + } + + .withHeader .body { + padding-top: calc(var(--modal-header-height) + var(--modal-content-block-padding)); + } + + .header { + display: grid; + grid-template-columns: minmax(2.25rem, 1fr) auto minmax(2.25rem, 1fr); + column-gap: 0.5rem; + align-items: center; + + min-height: var(--modal-header-height); + padding: 0.5rem 1rem; + } + + .headerWithSubtitle { + grid-template-rows: auto auto; + row-gap: 0.125rem; + } + + .title { + unicode-bidi: plaintext; + grid-column: 2; + grid-row: 1; + align-self: center; + + margin: 0; + + font-size: 1.125rem; + font-weight: var(--font-weight-semibold); + line-height: 1.25; + text-align: center; + } + + .headerWithSubtitle .title { + align-self: end; + } + + .subtitle { + unicode-bidi: plaintext; + grid-column: 2; + grid-row: 2; + align-self: start; + + font-size: 0.9375rem; + line-height: 1.2; + color: var(--color-text-secondary); + text-align: center; + } + + .headerAction { + display: flex; + grid-column: 3; + grid-row: 1 / -1; + align-items: center; + justify-self: end; + } + + .closeButton { + grid-column: 1; + grid-row: 1 / -1; + justify-self: start; + } + + .closeButtonAbsolute { + position: absolute; + z-index: 2; + top: 0.75rem; + inset-inline-start: 0.75rem; + } + + @keyframes panelOpen { + from { + transform: translateY(1rem) scale(0.98); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } + } + + @keyframes panelClose { + from { + transform: translateY(0) scale(1); + opacity: 1; + } + + to { + transform: translateY(1rem) scale(0.98); + opacity: 0; + } + } + + @keyframes backdropOpen { + from { + background-color: transparent; + } + + to { + background-color: rgb(0 0 0 / 30%); + } + } + + @keyframes backdropClose { + from { + background-color: rgb(0 0 0 / 30%); + } + + to { + background-color: transparent; + } + } + + :global(body.no-page-transitions) { + .dialog::backdrop, .panel { + animation: none !important; + } + } +} diff --git a/src/components/gili/modal/Modal.tsx b/src/components/gili/modal/Modal.tsx new file mode 100644 index 000000000..c1d3a2a39 --- /dev/null +++ b/src/components/gili/modal/Modal.tsx @@ -0,0 +1,477 @@ +import type { TeactNode } from '../../../lib/teact/teact'; +import { + createContext, + memo, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from '../../../lib/teact/teact'; + +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import buildClassName from '../../../util/buildClassName'; +import { waitForAnimationEnd } from '../../../util/cssAnimationEndListeners'; + +import useContext from '../../../hooks/data/useContext'; +import useFrozenProps from '../../../hooks/useFrozenProps'; +import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useUniqueId from '../../../hooks/useUniqueId'; + +import Button from '../../ui/Button'; +import Portal from '../../ui/Portal'; +import Surface from '../layout/Surface'; + +import styles from './Modal.module.scss'; + +const CLOSE_ANIMATION_DURATION = 200; + +let openModalCount = 0; + +export type ModalWidth = 'slim' | 'regular' | 'wide' | 'fullscreen'; +export type ModalHeight = 'auto' | 'regular' | 'tall' | 'fullscreen'; + +export type ModalProps = { + isOpen: boolean; + children: TeactNode; + header?: TeactNode; + dialogClassName?: string; + contentClassName?: string; + width?: ModalWidth; + height?: ModalHeight; + noBackdrop?: boolean; + noLightDismiss?: boolean; + ariaLabel?: string; + noContainment?: boolean; + onClose: NoneToVoidFunction; +}; + +type ModalContextType = { + titleId: string; + subtitleId: string; + hasSubtitle: boolean; + onClose: NoneToVoidFunction; + registerTitle: (isPresent: boolean) => void; + registerSubtitle: (isPresent: boolean) => void; +}; + +type ModalSlotProps = { + className?: string; + children?: TeactNode; +}; + +type ModalCloseButtonProps = { + asAbsolute?: boolean; + className?: string; +}; + +const ModalContext = createContext(undefined); + +const WIDTH_CLASS_NAME: Record = { + slim: styles.widthSlim, + regular: styles.widthRegular, + wide: styles.widthWide, + fullscreen: styles.widthFullscreen, +}; + +const HEIGHT_CLASS_NAME: Record = { + auto: styles.heightAuto, + regular: styles.heightRegular, + tall: styles.heightTall, + fullscreen: styles.heightFullscreen, +}; + +function useModalContext() { + return useContext(ModalContext); +} + +function addBodyDialogClass() { + openModalCount += 1; + requestMutation(() => { + document.body.classList.add('has-open-dialog'); + }); + + return () => { + openModalCount = Math.max(0, openModalCount - 1); + + if (!openModalCount) { + requestMutation(() => { + document.body.classList.remove('has-open-dialog'); + }); + } + }; +} + +const Modal = ({ + isOpen, + children, + header, + dialogClassName, + contentClassName, + width = 'regular', + height = 'regular', + noBackdrop, + noLightDismiss, + ariaLabel, + noContainment, + onClose, +}: ModalProps) => { + const [shouldRender, setShouldRender] = useState(Boolean(isOpen)); + const [isClosing, setIsClosing] = useState(false); + const [hasTitle, setHasTitle] = useState(false); + const [hasSubtitle, setHasSubtitle] = useState(false); + + const dialogRef = useRef(); + const panelRef = useRef(); + const closeAnimationCleanupRef = useRef(); + + const uniqueId = useUniqueId(); + const titleId = `modal-title-${uniqueId}`; + const subtitleId = `modal-subtitle-${uniqueId}`; + + const frozenProps = useFrozenProps({ + header, + children, + dialogClassName, + contentClassName, + width, + height, + noBackdrop, + ariaLabel, + noContainment, + }, !isOpen); + + const shouldShowHeader = Boolean(frozenProps.header); + + const cleanupCloseAnimation = useLastCallback(() => { + closeAnimationCleanupRef.current?.(); + closeAnimationCleanupRef.current = undefined; + }); + + const finishClose = useLastCallback(() => { + cleanupCloseAnimation(); + + const dialogElement = dialogRef.current; + + if (dialogElement?.open) { + dialogElement.close(); + } + + setIsClosing(false); + setShouldRender(false); + }); + + const handleRequestClose = useLastCallback(() => { + if (isClosing) return; + + onClose(); + }); + + const registerTitle = useLastCallback((isPresent: boolean) => { + setHasTitle(isPresent); + }); + + const registerSubtitle = useLastCallback((isPresent: boolean) => { + setHasSubtitle(isPresent); + }); + + const contextValue = useMemo(() => ({ + onClose: handleRequestClose, + titleId, + subtitleId, + hasSubtitle, + registerTitle, + registerSubtitle, + }), [ + handleRequestClose, + hasSubtitle, + registerSubtitle, + registerTitle, + subtitleId, + titleId, + ]); + + useEffect(() => { + if (isOpen) { + cleanupCloseAnimation(); + + if (!shouldRender) { + setShouldRender(true); + return; + } + + if (isClosing) { + setIsClosing(false); + } + + return; + } + + if (!shouldRender || isClosing) { + return; + } + + setIsClosing(true); + }, [isClosing, isOpen, shouldRender]); + + useEffect(() => { + if (!isClosing) { + cleanupCloseAnimation(); + return undefined; + } + + const panelElement = panelRef.current; + + if (!panelElement) { + finishClose(); + return undefined; + } + + if (document.body.classList.contains('no-page-transitions')) { + finishClose(); + return undefined; + } + + closeAnimationCleanupRef.current = waitForAnimationEnd( + panelElement, + finishClose, + undefined, + CLOSE_ANIMATION_DURATION + 100, + ); + + return cleanupCloseAnimation; + }, [isClosing]); + + useLayoutEffect(() => { + if (!shouldRender) { + return undefined; + } + + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + if (!dialogElement.open) { + dialogElement.showModal(); + } + + return () => { + cleanupCloseAnimation(); + + if (dialogElement.open) { + dialogElement.close(); + } + }; + }, [shouldRender]); + + useEffect(() => { + if (!shouldRender) { + return undefined; + } + + return addBodyDialogClass(); + }, [shouldRender]); + + useEffect(() => { + if (!shouldRender) { + return undefined; + } + + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + const handleCancel = (event: Event) => { + event.preventDefault(); + + if (noLightDismiss || !isOpen || isClosing) { + return; + } + + handleRequestClose(); + }; + + dialogElement.addEventListener('cancel', handleCancel); + + return () => { + dialogElement.removeEventListener('cancel', handleCancel); + }; + }, [isClosing, isOpen, noLightDismiss, shouldRender]); + + useHistoryBack({ + isActive: Boolean(isOpen && !noLightDismiss), + onBack: handleRequestClose, + }); + + const handleDialogClick = useLastCallback((event: React.MouseEvent) => { + if ( + event.target !== event.currentTarget + || noLightDismiss + || !isOpen + || isClosing + ) { + return; + } + + handleRequestClose(); + }); + + if (!shouldRender) { + return undefined; + } + + return ( + + + +
+ {shouldShowHeader && ( +
+ {frozenProps.header} +
+ )} + + +
+ {frozenProps.children} +
+
+
+
+
+
+ ); +}; + +const ModalHeader = ({ className, children }: ModalSlotProps) => { + const modalContext = useModalContext(); + + return ( +
+ {children} +
+ ); +}; + +const ModalHeaderAction = ({ className, children }: ModalSlotProps) => { + return ( +
+ {children} +
+ ); +}; + +const ModalTitle = ({ className, children }: ModalSlotProps) => { + const modalContext = useModalContext(); + + useLayoutEffect(() => { + modalContext?.registerTitle(true); + + return () => { + modalContext?.registerTitle(false); + }; + }, [modalContext]); + + return ( +
+ {children} +
+ ); +}; + +const ModalSubtitle = ({ className, children }: ModalSlotProps) => { + const modalContext = useModalContext(); + + useLayoutEffect(() => { + modalContext?.registerSubtitle(true); + + return () => { + modalContext?.registerSubtitle(false); + }; + }, [modalContext]); + + return ( +
+ {children} +
+ ); +}; + +const ModalCloseButton = ({ asAbsolute, className }: ModalCloseButtonProps) => { + const lang = useLang(); + const modalContext = useModalContext(); + + const handleClick = useLastCallback(() => { + modalContext?.onClose(); + }); + + return ( + + ); +}; + +export default memo(Modal); +export { + ModalHeader, + ModalHeaderAction, + ModalTitle, + ModalSubtitle, + ModalCloseButton, +}; diff --git a/src/components/gili/primitives/Switch.module.scss b/src/components/gili/primitives/Switch.module.scss index e0cdc70e8..93610d602 100644 --- a/src/components/gili/primitives/Switch.module.scss +++ b/src/components/gili/primitives/Switch.module.scss @@ -2,79 +2,119 @@ .root { cursor: var(--custom-cursor, pointer); + position: relative; + display: flex; flex-shrink: 0; align-items: center; width: 1.875rem; height: 0.875rem; + } + + .input { + cursor: inherit; + + position: absolute; + inset: 0; + + width: 100%; + height: 100%; margin: 0; + + opacity: 0; + } + + .track { + --switch-track-color: var(--ui-border-color, var(--color-borders-input)); + + display: flex; + align-items: center; + + width: 100%; + height: 100%; border-radius: 0.625rem; - appearance: none; - background-color: var(--ui-border-color, var(--color-borders-input)); + background-color: var(--switch-track-color); transition: background-color 0.15s ease, border-color 0.15s ease; + } - &::before { - content: ""; + .thumb { + transform: translateX(-0.125rem); - transform: translateX(-0.125rem); + display: flex; + align-items: center; + justify-content: center; - display: block; + width: 1.25rem; + height: 1.25rem; + border: 0.125rem solid var(--switch-track-color); + border-radius: 50%; - width: 1.25rem; - height: 1.25rem; - border: 0.125rem solid var(--ui-border-color, var(--color-borders-input)); - border-radius: 50%; + color: var(--switch-track-color); - background-color: var(--ui-bg-color, var(--color-background)); + background-color: var(--ui-bg-color, var(--color-background)); - transition: transform 0.15s ease, border-color 0.15s ease; - } + transition: transform 0.15s ease, border-color 0.15s ease; + } - &:checked { - border-color: var(--ui-accent-color, var(--color-primary)); - background-color: var(--ui-accent-color, var(--color-primary)); + .input { + &:checked + .track { + --switch-track-color: var(--ui-accent-color, var(--color-primary)); - &::before { + .thumb { transform: translateX(0.75rem); - border-color: var(--ui-accent-color, var(--color-primary)); } } - &:disabled { - cursor: default; - opacity: var(--input-disabled-opacity, 0.5); - } - - &:focus-visible { + &:focus-visible + .track { outline: 2px solid var(--ui-accent-color, var(--color-primary)); outline-offset: 2px; } } + .disabled { + cursor: default; + opacity: var(--input-disabled-opacity, 0.5); + } + + .locked { + cursor: default; + } + + .lockIconTransition { + transform: scale(0.5); + + display: flex; + align-items: center; + justify-content: center; + + transition: transform 0.15s ease; + + &:global(.open) { + transform: scale(1); + } + } + + .lockIcon { + font-size: 0.625rem; + } + .permissionColors { - background-color: var(--color-error); - - &::before { - border-color: var(--color-error); + .track { + --switch-track-color: var(--color-error); } - &:checked { - border-color: var(--color-green); - background-color: var(--color-green); - - &::before { - border-color: var(--color-green); - } + .input:checked + .track { + --switch-track-color: var(--color-green); } - &:focus-visible { + .input:focus-visible + .track { outline-color: var(--color-error); } - &:checked:focus-visible { + .input:checked:focus-visible + .track { outline-color: var(--color-green); } } diff --git a/src/components/gili/primitives/Switch.tsx b/src/components/gili/primitives/Switch.tsx index dcf2bc998..9610e9c82 100644 --- a/src/components/gili/primitives/Switch.tsx +++ b/src/components/gili/primitives/Switch.tsx @@ -4,6 +4,8 @@ import buildClassName from '../../../util/buildClassName'; import useLastCallback from '../../../hooks/useLastCallback'; +import Icon from '../../common/icons/Icon'; +import ShowTransition from '../../ui/ShowTransition'; import { useControlContext } from '../layout/Control'; import { useInteractiveContext } from '../layout/Interactive'; @@ -17,6 +19,7 @@ type InputProps = React.DetailedHTMLProps< type OwnProps = { checked: boolean; disabled?: boolean; + locked?: boolean; withPermissionColors?: boolean; className?: string; onChange?: (checked: boolean) => void; @@ -27,6 +30,7 @@ type Props = OwnProps & Omit; const Switch = ({ checked, disabled, + locked, withPermissionColors, className, onChange, @@ -40,27 +44,42 @@ const Switch = ({ const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading; const handleChange = useLastCallback((e: React.ChangeEvent) => { + if (locked) return; + onChange?.(e.currentTarget.checked); }); if (interactive?.isLoading) return undefined; return ( - + > + + + + + + + + + ); }; diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 7c53f8548..790e080b9 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -226,7 +226,10 @@ const MediaViewer = ({ const prevOrigin = usePrevious(origin); const prevItem = usePrevious(currentItem); const prevBestImageData = usePrevious(bestImageData); - const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined; + const textMessage = currentItem?.type === 'message' ? currentItem.message : undefined; + const textParts = textMessage + ? renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true }) + : undefined; const hasFooter = Boolean(textParts); useEffectWithPrevDeps(([prevIsOpen, prevIsHidden]) => { diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index 4b75eb003..5c0c93296 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -57,12 +57,11 @@ export type OwnProps = { theme: ThemeKey; canEditMedia?: boolean; editingMessage?: ApiMessage; - messageListType?: MessageListType; + messageListType: MessageListType; paidMessagesStars?: number; canInsertDate?: boolean; onFileSelect: (files: File[]) => void; onDateInsert: (text: ApiFormattedText) => void; - onPollCreate: NoneToVoidFunction; onTodoListCreate: NoneToVoidFunction; onMenuOpen: NoneToVoidFunction; onMenuClose: NoneToVoidFunction; @@ -93,10 +92,10 @@ const AttachMenu = ({ onDateInsert, onMenuOpen, onMenuClose, - onPollCreate, onTodoListCreate, }: OwnProps) => { const { + openPollModal, updateAttachmentSettings, } = getActions(); const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag(); @@ -190,6 +189,11 @@ const AttachMenu = ({ openDateModal(); }); + const handlePollCreate = useLastCallback(() => { + closeAttachMenu(); + openPollModal({ chatId, threadId, messageListType }); + }); + if (!isButtonVisible && !isDateModalOpen) { return undefined; } @@ -275,7 +279,7 @@ const AttachMenu = ({ )} {canAttachPolls && !editingMessage && ( - {oldLang('Poll')} + {lang('Poll')} )} {canAttachToDoLists && !editingMessage && ( {lang('TitleToDoList')} diff --git a/src/components/middle/composer/PollModal.scss b/src/components/middle/composer/PollModal.scss deleted file mode 100644 index 41c5579b8..000000000 --- a/src/components/middle/composer/PollModal.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use '../../../styles/mixins'; - -.PollModal { - .modal-dialog { - max-width: 26.25rem; - max-height: calc(100vh - 5rem); - } - - .modal-content { - min-height: 4.875rem; - } - - .modal-header-condensed { - margin-bottom: 1rem; - } - - .options-header { - margin-top: 0.5rem; - margin-bottom: 0.75rem; - - font-size: 1rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - } - - .options-list { - overflow: auto; - overflow-y: scroll; - - max-height: 20rem; - margin: 1rem -0.75rem -0.5rem; - padding: 0 0.75rem; - border-top: 1px solid var(--color-chat-hover); - - @include mixins.adapt-padding-to-scrollbar(0.75rem); - - @media (max-width: 600px) { - overflow: hidden; - max-height: none; - } - } - - .option-wrapper { - position: relative; - - .form-control { - padding-right: 3rem; - } - - .option-remove-button { - position: absolute; - top: 0.125rem; - right: 0.3125rem; - } - } - - .quiz-mode { - margin-top: 1.5rem; - - .dialog-checkbox-group { - margin: 0 -1.125rem; - } - - .options-header { - margin-bottom: 0.5rem; - } - - .note { - margin-top: -1rem; - } - } - - .note { - font-size: 0.875rem; - color: var(--color-text-secondary); - } - - .poll-error { - margin: -1rem 0 1rem 0.25rem; - font-size: 0.875rem; - color: var(--color-error); - } - - .input-group:last-child { - margin-bottom: 0.5rem; - } - - .radio-group { - display: flex; - flex-direction: column; - gap: 0.8125rem; - margin-left: -1.125rem; - - .Radio, &:hover { - background-color: transparent; - } - } - - .Checkbox, - .Radio { - .Checkbox-main, - .Radio-main { - width: 100%; - max-height: 3rem; - } - } -} diff --git a/src/components/middle/composer/PollModal.tsx b/src/components/middle/composer/PollModal.tsx deleted file mode 100644 index 59a62c499..000000000 --- a/src/components/middle/composer/PollModal.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import type { ChangeEvent } from 'react'; -import type { ElementRef } from '../../../lib/teact/teact'; -import { - memo, useEffect, useRef, useState, -} from '../../../lib/teact/teact'; - -import type { ApiNewPoll } from '../../../api/types'; - -import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; -import captureEscKeyListener from '../../../util/captureEscKeyListener'; -import { generateUniqueNumberId } from '../../../util/generateUniqueId'; -import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; - -import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; - -import Button from '../../ui/Button'; -import Checkbox from '../../ui/Checkbox'; -import InputText from '../../ui/InputText'; -import Modal from '../../ui/Modal'; -import RadioGroup from '../../ui/RadioGroup'; -import TextArea from '../../ui/TextArea'; - -import './PollModal.scss'; - -export type OwnProps = { - isOpen: boolean; - shouldBeAnonymous?: boolean; - isQuiz?: boolean; - maxOptionsCount?: number; - onSend: (pollSummary: ApiNewPoll) => void; - onClear: () => void; -}; - -const MAX_LIST_HEIGHT = 320; -const FALLBACK_MAX_OPTIONS_COUNT = 12; -const MAX_OPTION_LENGTH = 100; -const MAX_QUESTION_LENGTH = 255; -const MAX_SOLUTION_LENGTH = 200; - -const PollModal = ({ - isOpen, - isQuiz, - shouldBeAnonymous, - maxOptionsCount = FALLBACK_MAX_OPTIONS_COUNT, - onSend, - onClear, -}: OwnProps) => { - const questionInputRef = useRef(); - const optionsListRef = useRef(); - - const [question, setQuestion] = useState(''); - const [options, setOptions] = useState(['']); - const [isAnonymous, setIsAnonymous] = useState(true); - const [isMultipleAnswers, setIsMultipleAnswers] = useState(false); - const [isQuizMode, setIsQuizMode] = useState(isQuiz || false); - const [solution, setSolution] = useState(''); - const [correctOption, setCorrectOption] = useState(); - const [hasErrors, setHasErrors] = useState(false); - - const lang = useOldLang(); - - const handleSolutionChange = useLastCallback((e: ChangeEvent) => { - setSolution(e.target.value); - }); - - const focusInput = useLastCallback((ref: ElementRef) => { - if (isOpen && ref.current) { - ref.current.focus(); - } - }); - - useEffect(() => (isOpen ? captureEscKeyListener(onClear) : undefined), [isOpen, onClear]); - useEffect(() => { - if (!isOpen) { - setQuestion(''); - setOptions(['']); - setIsAnonymous(true); - setIsMultipleAnswers(false); - setIsQuizMode(isQuiz || false); - setSolution(''); - setCorrectOption(undefined); - setHasErrors(false); - } - }, [isQuiz, isOpen]); - - useEffect(() => focusInput(questionInputRef), [focusInput, isOpen]); - - const addNewOption = useLastCallback((newOptions: string[] = []) => { - setOptions([...newOptions, '']); - - requestNextMutation(() => { - const list = optionsListRef.current; - if (!list) { - return; - } - - requestMeasure(() => { - list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); - }); - }); - }); - - const handleCreate = useLastCallback(() => { - setHasErrors(false); - if (!isOpen) { - return; - } - - const isNoCorrectOptionError = isQuizMode && (correctOption === undefined || !options[correctOption].trim()); - - const answers = options - .map((text, index) => { - text = text.trim(); - - if (!text) return undefined; - - return { - text: { - text, - }, - option: String(index), - ...(index === correctOption && { correct: true }), - }; - }).filter(Boolean); - - const questionTrimmed = question.trim().substring(0, MAX_QUESTION_LENGTH); - if (!questionTrimmed || answers.length < 2) { - setQuestion(questionTrimmed); - if (answers.length) { - const optionsTrimmed = options.map((o) => o.trim().substring(0, MAX_OPTION_LENGTH)).filter(Boolean); - if (optionsTrimmed.length < 2) { - addNewOption(optionsTrimmed); - } else { - setOptions(optionsTrimmed); - } - } else { - addNewOption(); - } - setHasErrors(true); - return; - } - - if (isNoCorrectOptionError) { - setHasErrors(true); - return; - } - - const payload: ApiNewPoll = { - summary: { - id: generateUniqueNumberId().toString(), - hash: '0', - question: { - text: questionTrimmed, - }, - answers, - isPublic: !isAnonymous || undefined, - isMultipleChoice: isMultipleAnswers || undefined, - isQuiz: isQuizMode || undefined, - }, - }; - - if (isQuizMode) { - const { text, entities } = (solution && parseHtmlAsFormattedText(solution.substring(0, MAX_SOLUTION_LENGTH))) - || {}; - const correctAnswerIndex = answers.findIndex((answer) => answer.option === String(correctOption!)); - - payload.correctAnswers = [correctAnswerIndex]; - payload.solution = text; - payload.solutionEntities = entities; - } - - onSend(payload); - }); - - const updateOption = useLastCallback((index: number, text: string) => { - const newOptions = [...options]; - newOptions[index] = text; - if (newOptions[newOptions.length - 1].trim().length && newOptions.length < maxOptionsCount) { - addNewOption(newOptions); - } else { - setOptions(newOptions); - } - }); - - const removeOption = useLastCallback((index: number) => { - const newOptions = [...options]; - newOptions.splice(index, 1); - setOptions(newOptions); - - if (correctOption !== undefined) { - if (correctOption === index) { - setCorrectOption(undefined); - } else if (index < correctOption) { - setCorrectOption(correctOption - 1); - } - } - - requestNextMutation(() => { - if (!optionsListRef.current) { - return; - } - - optionsListRef.current.classList.toggle('overflown', optionsListRef.current.scrollHeight > MAX_LIST_HEIGHT); - }); - }); - - const handleCorrectOptionChange = useLastCallback((newValue: string) => { - setCorrectOption(Number(newValue)); - }); - - const handleIsAnonymousChange = useLastCallback((e: ChangeEvent) => { - setIsAnonymous(e.target.checked); - }); - - const handleMultipleAnswersChange = useLastCallback((e: ChangeEvent) => { - setIsMultipleAnswers(e.target.checked); - }); - - const handleQuizModeChange = useLastCallback((e: ChangeEvent) => { - setIsQuizMode(e.target.checked); - }); - - const handleKeyPress = useLastCallback((e: React.KeyboardEvent) => { - if (e.keyCode === 13) { - handleCreate(); - } - }); - - const handleQuestionChange = useLastCallback((e: ChangeEvent) => { - setQuestion(e.target.value); - }); - - const getQuestionError = useLastCallback(() => { - if (hasErrors && !question.trim().length) { - return lang('lng_polls_choose_question'); - } - - return undefined; - }); - - const getOptionsError = useLastCallback((index: number) => { - const optionsTrimmed = options.map((o) => o.trim()).filter((o) => o.length); - if (hasErrors && optionsTrimmed.length < 2 && !options[index].trim().length) { - return lang('lng_polls_choose_answers'); - } - return undefined; - }); - - function renderHeader() { - return ( -
- -
- ); - } - - function renderOptions() { - return options.map((option, index) => ( -
- updateOption(index, e.currentTarget.value)} - onKeyPress={handleKeyPress} - /> - {index !== options.length - 1 && ( -
- )); - } - - function renderRadioOptions() { - return renderOptions() - .map((label, index) => ({ value: String(index), label, hidden: !options[index].trim() })); - } - - function renderQuizNoOptionError() { - const optionsTrimmed = options.map((o) => o.trim()).filter((o) => o.length); - - return isQuizMode && (correctOption === undefined || !optionsTrimmed[correctOption]) && ( -

{lang('lng_polls_choose_correct')}

- ); - } - - return ( - - -
- -
-

{lang('PollOptions')}

- - {hasErrors && renderQuizNoOptionError()} - {isQuizMode ? ( - - ) : ( - renderOptions() - )} - -
- -
- -
-
- {!shouldBeAnonymous && ( - - )} - - -
- {isQuizMode && ( - <> -

{lang('lng_polls_solution_title')}

-